self::SEASON_CODE_REGEX], priority: 10, )] public function index(Season $season, Request $request): Response { $label = null; $labelSlug = $request->query->getString('label'); if ('' !== $labelSlug) { $label = $this->questionLabelRepository->findBySlugAndSeason($labelSlug, $season); } return $this->render('backoffice/season.html.twig', [ 'season' => $season, 'bankQuestions' => $this->bankQuestionRepository->findBySeason($season, $label), 'assignableQuizzes' => $this->quizRepository->findAssignableForSeason($season), 'activeLabel' => $label, 'labelColours' => LabelColour::cases(), 'activeTab' => 'question-bank', 'template' => 'backoffice/season/tab_question_bank.html.twig', ]); } #[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/question-bank/new', name: 'tvdt_backoffice_question_bank_new', requirements: ['seasonCode' => self::SEASON_CODE_REGEX], priority: 10, )] public function new(Season $season, Request $request): Response { $bankQuestion = new BankQuestion(); $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->applyAnswerOrdering($bankQuestion); $season->addBankQuestion($bankQuestion); $this->em->persist($bankQuestion); $this->em->flush(); $this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } return $this->render('backoffice/question_bank/form.html.twig', [ 'season' => $season, 'form' => $form, 'bankQuestion' => null, ]); } #[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/edit', name: 'tvdt_backoffice_question_bank_edit', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID], priority: 10, )] public function edit(Season $season, BankQuestion $bankQuestion, Request $request): Response { $this->assertSameSeason($season, $bankQuestion->season); $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->em->flush(); $this->syncUsagesAfterEdit($bankQuestion); $this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } return $this->render('backoffice/question_bank/form.html.twig', [ 'season' => $season, 'form' => $form, 'bankQuestion' => $bankQuestion, ]); } #[IsCsrfTokenValid('delete_bank_question')] #[IsGranted(SeasonVoter::DELETE, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/delete', name: 'tvdt_backoffice_question_bank_delete', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID], methods: ['POST'], priority: 10, )] public function delete(Season $season, BankQuestion $bankQuestion): RedirectResponse { $this->assertSameSeason($season, $bankQuestion->season); $hasLockedUsages = $bankQuestion->usages->exists( static fn (int $key, BankQuestionUsage $usage): bool => $usage->quiz->isLocked, ); if ($hasLockedUsages) { $this->addFlash(FlashType::Danger, $this->translator->trans('This question cannot be deleted because it is used in a locked or active quiz')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } $this->em->remove($bankQuestion); $this->em->flush(); $this->addFlash(FlashType::Success, $this->translator->trans('Question removed from the question bank')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } #[IsCsrfTokenValid('assign_bank_question')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/assign', name: 'tvdt_backoffice_question_bank_assign', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID], methods: ['POST'], priority: 10, )] public function assign(Season $season, BankQuestion $bankQuestion, Request $request): RedirectResponse { $this->assertSameSeason($season, $bankQuestion->season); $quizId = $request->request->getString('quiz'); if (!Uuid::isValid($quizId)) { throw new BadRequestHttpException('Invalid quiz'); } $quiz = $this->em->getRepository(Quiz::class)->find($quizId); if (!$quiz instanceof Quiz || $quiz->season !== $season) { throw new BadRequestHttpException('Invalid quiz'); } $this->denyAccessUnlessGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, $quiz); try { $this->questionBankService->assignToQuiz($bankQuestion, $quiz); $this->addFlash(FlashType::Success, $this->translator->trans('Question added to quiz %quiz%', ['%quiz%' => $quiz->name])); } catch (QuizLockedException) { $this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered')); } catch (BankQuestionAlreadyUsedException) { $this->addFlash(FlashType::Danger, $this->translator->trans('This question has already been used')); } catch (BankQuestionIncompleteException) { $this->addFlash(FlashType::Warning, $this->translator->trans('This question is incomplete: it needs at least two answers and exactly one correct answer')); } return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } #[IsCsrfTokenValid('add_question_label')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/question-bank/labels', name: 'tvdt_backoffice_question_bank_labels', requirements: ['seasonCode' => self::SEASON_CODE_REGEX], methods: ['POST'], priority: 15, )] public function addLabel(Season $season, Request $request): RedirectResponse { $name = mb_trim($request->request->getString('name')); if ('' === $name || mb_strlen($name) > 64) { $this->addFlash(FlashType::Danger, $this->translator->trans('Invalid label name')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } $slug = mb_strtolower($this->slugger->slug($name)->toString()); $colour = LabelColour::tryFrom($request->request->getString('colour')) ?? LabelColour::Gray; $exists = $season->questionLabels->exists(static fn (int $key, QuestionLabel $label): bool => $label->name === $name); if (!$exists) { if ($this->questionLabelRepository->slugExistsForSeason($slug, $season)) { $this->addFlash(FlashType::Danger, $this->translator->trans('A label with a similar name already exists')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } try { $newLabel = new QuestionLabel($name); $newLabel->slug = $slug; $newLabel->colour = $colour; $season->addQuestionLabel($newLabel); $this->em->flush(); $this->addFlash(FlashType::Success, $this->translator->trans('Label added')); } catch (UniqueConstraintViolationException) { // Concurrent request already inserted the same label; treat as a no-op } } return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } #[IsCsrfTokenValid('delete_question_label')] #[IsGranted(SeasonVoter::DELETE, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/question-bank/labels/{labelSlug}/delete', name: 'tvdt_backoffice_question_bank_label_delete', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'labelSlug' => '[a-z0-9-]+'], methods: ['POST'], priority: 15, )] public function deleteLabel(Season $season, string $labelSlug): RedirectResponse { $label = $this->questionLabelRepository->findBySlugAndSeason($labelSlug, $season); if (!$label instanceof QuestionLabel) { throw $this->createNotFoundException(); } foreach ($label->bankQuestions as $bankQuestion) { $bankQuestion->removeLabel($label); } $this->em->remove($label); $this->em->flush(); $this->addFlash(FlashType::Success, $this->translator->trans('Label removed')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } #[IsCsrfTokenValid('unassign_bank_question')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/unassign/{usage}', name: 'tvdt_backoffice_question_bank_unassign', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID], methods: ['POST'], priority: 10, )] public function unassign(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse { $this->assertSameSeason($season, $bankQuestion->season); if ($usage->bankQuestion !== $bankQuestion) { throw new NotFoundHttpException(); } if ($usage->quiz->isLocked) { $this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } $this->questionBankService->unassignFromQuiz($usage); $this->addFlash(FlashType::Success, $this->translator->trans('Question removed from quiz %quiz%', ['%quiz%' => $usage->quiz->name])); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } #[IsCsrfTokenValid('sync_bank_question')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/sync/{usage}', name: 'tvdt_backoffice_question_bank_sync', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID], methods: ['POST'], priority: 10, )] public function syncToQuiz(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse { $this->assertSameSeason($season, $bankQuestion->season); if ($usage->bankQuestion !== $bankQuestion) { throw new NotFoundHttpException(); } if ($usage->quiz->isLocked) { $this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered')); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } $this->questionBankService->syncToQuiz($bankQuestion, $usage); $this->em->flush(); $this->addFlash(FlashType::Success, $this->translator->trans('Question synced to quiz %quiz%', ['%quiz%' => $usage->quiz->name])); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } private function assertSameSeason(Season $season, Season $subjectSeason): void { if ($season !== $subjectSeason) { throw new NotFoundHttpException(); } } private function applyAnswerOrdering(BankQuestion $bankQuestion): void { $ordering = 1; foreach ($bankQuestion->answers as $answer) { $answer->ordering = $ordering++; } } private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void { $pendingNames = []; $synced = false; foreach ($bankQuestion->usages as $usage) { if (!$usage->quiz->isLocked) { $this->questionBankService->syncToQuiz($bankQuestion, $usage); $synced = true; } else { $pendingNames[] = $usage->quiz->name; } } if ($synced) { $this->em->flush(); } if ([] !== $pendingNames) { $this->addFlash( FlashType::Warning, $this->translator->trans( 'The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.', ['%quizzes%' => implode(', ', $pendingNames)], ), ); } } }