From c34c25dff7400b1b07eba5e68475ee42bd23afc2 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Sat, 4 Jul 2026 20:10:03 +0200 Subject: [PATCH] feat: add question bank management, quiz finalization, and related backend/frontend functionality --- .idea/php.xml | 324 ++++++++--------- .../bo/form_collection_controller.js | 21 ++ migrations/Version20260704151112.php | 66 ++++ .../Backoffice/QuestionBankController.php | 262 ++++++++++++++ src/Controller/Backoffice/QuizController.php | 56 ++- .../Backoffice/SeasonController.php | 38 +- src/DataFixtures/DevFixtures.php | 35 ++ src/DataFixtures/KrtekFixtures.php | 65 +++- src/Entity/BankAnswer.php | 39 +++ src/Entity/BankQuestion.php | 129 +++++++ src/Entity/BankQuestionUsage.php | 36 ++ src/Entity/QuestionLabel.php | 43 +++ src/Entity/Quiz.php | 27 ++ src/Entity/Season.php | 32 ++ .../BankQuestionAlreadyUsedException.php | 7 + src/Exception/QuizLockedException.php | 7 + src/Form/BankAnswerFormType.php | 38 ++ src/Form/BankQuestionFormType.php | 75 ++++ src/Repository/BankQuestionRepository.php | 43 +++ src/Repository/QuestionLabelRepository.php | 18 + src/Repository/QuizRepository.php | 24 ++ src/Security/Voter/SeasonVoter.php | 35 +- src/Service/QuestionBankService.php | 61 ++++ .../backoffice/question_bank/form.html.twig | 55 +++ .../backoffice/quiz/tab_overview.html.twig | 30 +- templates/backoffice/season.html.twig | 51 +-- .../season/tab_candidates.html.twig | 17 + .../season/tab_question_bank.html.twig | 109 ++++++ .../backoffice/season/tab_settings.html.twig | 6 + .../backoffice/season/tab_tests.html.twig | 22 ++ .../Controller/Backoffice/QueryCountTest.php | 77 +++++ .../Backoffice/QuestionBankControllerTest.php | 326 ++++++++++++++++++ .../Backoffice/QuizFinalizeTest.php | 215 ++++++++++++ .../Repository/BankQuestionRepositoryTest.php | 53 +++ tests/Security/Voter/SeasonVoterTest.php | 57 +++ translations/messages+intl-icu.nl.xliff | 172 +++++++++ translations/validators.nl.xliff | 28 ++ 37 files changed, 2493 insertions(+), 206 deletions(-) create mode 100644 assets/controllers/bo/form_collection_controller.js create mode 100644 migrations/Version20260704151112.php create mode 100644 src/Controller/Backoffice/QuestionBankController.php create mode 100644 src/DataFixtures/DevFixtures.php create mode 100644 src/Entity/BankAnswer.php create mode 100644 src/Entity/BankQuestion.php create mode 100644 src/Entity/BankQuestionUsage.php create mode 100644 src/Entity/QuestionLabel.php create mode 100644 src/Exception/BankQuestionAlreadyUsedException.php create mode 100644 src/Exception/QuizLockedException.php create mode 100644 src/Form/BankAnswerFormType.php create mode 100644 src/Form/BankQuestionFormType.php create mode 100644 src/Repository/BankQuestionRepository.php create mode 100644 src/Repository/QuestionLabelRepository.php create mode 100644 src/Service/QuestionBankService.php create mode 100644 templates/backoffice/question_bank/form.html.twig create mode 100644 templates/backoffice/season/tab_candidates.html.twig create mode 100644 templates/backoffice/season/tab_question_bank.html.twig create mode 100644 templates/backoffice/season/tab_settings.html.twig create mode 100644 templates/backoffice/season/tab_tests.html.twig create mode 100644 tests/Controller/Backoffice/QueryCountTest.php create mode 100644 tests/Controller/Backoffice/QuestionBankControllerTest.php create mode 100644 tests/Controller/Backoffice/QuizFinalizeTest.php create mode 100644 tests/Repository/BankQuestionRepositoryTest.php diff --git a/.idea/php.xml b/.idea/php.xml index 0946da5..5322ca7 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -41,169 +41,169 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/controllers/bo/form_collection_controller.js b/assets/controllers/bo/form_collection_controller.js new file mode 100644 index 0000000..a7624d6 --- /dev/null +++ b/assets/controllers/bo/form_collection_controller.js @@ -0,0 +1,21 @@ +import {Controller} from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['collection']; + static values = {prototype: String}; + + connect() { + this.index = this.collectionTarget.children.length; + } + + addItem() { + const item = document.createElement('div'); + item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index); + this.collectionTarget.appendChild(item.firstElementChild); + this.index++; + } + + removeItem(event) { + event.target.closest('[data-collection-item]').remove(); + } +} diff --git a/migrations/Version20260704151112.php b/migrations/Version20260704151112.php new file mode 100644 index 0000000..c5f002a --- /dev/null +++ b/migrations/Version20260704151112.php @@ -0,0 +1,66 @@ +addSql('CREATE TABLE bank_answer (id UUID NOT NULL, ordering SMALLINT DEFAULT 0 NOT NULL, text VARCHAR(255) NOT NULL, is_right_answer BOOLEAN NOT NULL, bank_question_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_FAB865583CAC40C0 ON bank_answer (bank_question_id)'); + $this->addSql('CREATE TABLE bank_question (id UUID NOT NULL, question VARCHAR(255) NOT NULL, reusable BOOLEAN DEFAULT false NOT NULL, season_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_87B753C94EC001D1 ON bank_question (season_id)'); + $this->addSql('CREATE TABLE bank_question_question_label (bank_question_id UUID NOT NULL, question_label_id UUID NOT NULL, PRIMARY KEY (bank_question_id, question_label_id))'); + $this->addSql('CREATE INDEX IDX_856E26833CAC40C0 ON bank_question_question_label (bank_question_id)'); + $this->addSql('CREATE INDEX IDX_856E268350B19F35 ON bank_question_question_label (question_label_id)'); + $this->addSql('CREATE TABLE bank_question_usage (id UUID NOT NULL, created TIMESTAMP(0) WITH TIME ZONE NOT NULL, bank_question_id UUID NOT NULL, quiz_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_775833AD3CAC40C0 ON bank_question_usage (bank_question_id)'); + $this->addSql('CREATE INDEX IDX_775833AD853CD175 ON bank_question_usage (quiz_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_775833AD3CAC40C0853CD175 ON bank_question_usage (bank_question_id, quiz_id)'); + $this->addSql('CREATE TABLE question_label (id UUID NOT NULL, name VARCHAR(64) NOT NULL, season_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_3E4C41EC4EC001D1 ON question_label (season_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_3E4C41EC5E237E064EC001D1 ON question_label (name, season_id)'); + $this->addSql('ALTER TABLE bank_answer ADD CONSTRAINT FK_FAB865583CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE bank_question ADD CONSTRAINT FK_87B753C94EC001D1 FOREIGN KEY (season_id) REFERENCES season (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE bank_question_question_label ADD CONSTRAINT FK_856E26833CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE bank_question_question_label ADD CONSTRAINT FK_856E268350B19F35 FOREIGN KEY (question_label_id) REFERENCES question_label (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_775833AD3CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_775833AD853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE question_label ADD CONSTRAINT FK_3E4C41EC4EC001D1 FOREIGN KEY (season_id) REFERENCES season (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE quiz ADD finalized_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); + // Backfill: quizzes that are currently active must stay valid under the new "finalized before activation" rule + $this->addSql('UPDATE quiz SET finalized_at = NOW() WHERE id IN (SELECT active_quiz_id FROM season WHERE active_quiz_id IS NOT NULL)'); + } + + #[\Override] + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE bank_answer DROP CONSTRAINT FK_FAB865583CAC40C0'); + $this->addSql('ALTER TABLE bank_question DROP CONSTRAINT FK_87B753C94EC001D1'); + $this->addSql('ALTER TABLE bank_question_question_label DROP CONSTRAINT FK_856E26833CAC40C0'); + $this->addSql('ALTER TABLE bank_question_question_label DROP CONSTRAINT FK_856E268350B19F35'); + $this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_775833AD3CAC40C0'); + $this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_775833AD853CD175'); + $this->addSql('ALTER TABLE question_label DROP CONSTRAINT FK_3E4C41EC4EC001D1'); + $this->addSql('DROP TABLE bank_answer'); + $this->addSql('DROP TABLE bank_question'); + $this->addSql('DROP TABLE bank_question_question_label'); + $this->addSql('DROP TABLE bank_question_usage'); + $this->addSql('DROP TABLE question_label'); + $this->addSql('ALTER TABLE quiz DROP finalized_at'); + } +} diff --git a/src/Controller/Backoffice/QuestionBankController.php b/src/Controller/Backoffice/QuestionBankController.php new file mode 100644 index 0000000..4002741 --- /dev/null +++ b/src/Controller/Backoffice/QuestionBankController.php @@ -0,0 +1,262 @@ + self::SEASON_CODE_REGEX], + priority: 10, + )] + public function index(Season $season, Request $request): Response + { + $label = null; + $labelId = $request->query->getString('label'); + if ('' !== $labelId && Uuid::isValid($labelId)) { + $label = $this->em->getRepository(QuestionLabel::class)->find($labelId); + if ($label instanceof QuestionLabel && $label->season !== $season) { + $label = null; + } + } + + return $this->render('backoffice/season.html.twig', [ + 'season' => $season, + 'bankQuestions' => $this->bankQuestionRepository->findBySeason($season, $label), + 'assignableQuizzes' => $this->quizRepository->findAssignableForSeason($season), + 'activeLabel' => $label, + '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->applyAnswerOrdering($bankQuestion); + $this->em->flush(); + + $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); + + $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')); + } + + 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]); + } + + $exists = $season->questionLabels->exists(static fn (int $key, QuestionLabel $label): bool => $label->name === $name); + if (!$exists) { + $season->addQuestionLabel(new QuestionLabel($name)); + $this->em->flush(); + $this->addFlash(FlashType::Success, $this->translator->trans('Label added')); + } + + 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/{label}/delete', + name: 'tvdt_backoffice_question_bank_label_delete', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'label' => Requirement::UUID], + methods: ['POST'], + priority: 15, + )] + public function deleteLabel(Season $season, QuestionLabel $label): RedirectResponse + { + $this->assertSameSeason($season, $label->season); + + 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]); + } + + 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++; + } + } +} diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index c12ffae..52e6d26 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tvdt\Controller\Backoffice; use Doctrine\ORM\EntityManagerInterface; +use Safe\DateTimeImmutable; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -22,6 +23,7 @@ use Tvdt\Entity\Question; use Tvdt\Entity\Quiz; use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\Season; +use Tvdt\Enum\FlashType; use Tvdt\Exception\ErrorClearingQuizException; use Tvdt\Repository\QuizCandidateRepository; use Tvdt\Repository\QuizRepository; @@ -252,6 +254,12 @@ class QuizController extends AbstractController )] public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse { + if ($quiz instanceof Quiz && !$quiz->isFinalized()) { + $this->addFlash(FlashType::Danger, $this->translator->trans('The quiz must be finalized before it can be activated')); + + return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); + } + $season->activeQuiz = $quiz; $this->em->flush(); @@ -274,7 +282,9 @@ class QuizController extends AbstractController { try { $this->quizRepository->clearQuiz($quiz); - $this->addFlash('success', $this->translator->trans('Quiz cleared')); + $quiz->finalizedAt = null; + $this->em->flush(); + $this->addFlash('success', $this->translator->trans('Quiz cleared and no longer finalized')); } catch (ErrorClearingQuizException) { $this->addFlash('error', $this->translator->trans('Error clearing quiz')); } @@ -282,6 +292,50 @@ class QuizController extends AbstractController return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); } + #[IsCsrfTokenValid('finalize_quiz')] + #[IsGranted(SeasonVoter::EDIT, subject: 'quiz')] + #[Route( + '/backoffice/quiz/{quiz}/finalize', + name: 'tvdt_backoffice_quiz_finalize', + requirements: ['quiz' => Requirement::UUID], + methods: ['POST'], + )] + public function finalizeQuiz(Quiz $quiz): RedirectResponse + { + if ($quiz->questions->isEmpty() || [] !== $quiz->getQuestionErrors()) { + $this->addFlash(FlashType::Warning, $this->translator->trans('The quiz cannot be finalized while it has errors')); + } elseif (!$quiz->isFinalized()) { + $quiz->finalizedAt = new DateTimeImmutable(); + $this->em->flush(); + $this->addFlash('success', $this->translator->trans('Quiz finalized')); + } + + return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); + } + + #[IsCsrfTokenValid('unfinalize_quiz')] + #[IsGranted(SeasonVoter::EDIT, subject: 'quiz')] + #[Route( + '/backoffice/quiz/{quiz}/unfinalize', + name: 'tvdt_backoffice_quiz_unfinalize', + requirements: ['quiz' => Requirement::UUID], + methods: ['POST'], + )] + public function unfinalizeQuiz(Quiz $quiz): RedirectResponse + { + if ($quiz->hasStartedCandidates()) { + $this->addFlash(FlashType::Danger, $this->translator->trans('The quiz has already been filled in and can no longer be altered')); + } elseif ($quiz->season->activeQuiz === $quiz) { + $this->addFlash(FlashType::Danger, $this->translator->trans('Deactivate the quiz before undoing the finalization')); + } else { + $quiz->finalizedAt = null; + $this->em->flush(); + $this->addFlash('success', $this->translator->trans('Quiz is no longer finalized')); + } + + return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); + } + #[IsCsrfTokenValid('delete_quiz')] #[IsGranted(SeasonVoter::DELETE, subject: 'quiz')] #[Route( diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php index 198ca9a..292cef3 100644 --- a/src/Controller/Backoffice/SeasonController.php +++ b/src/Controller/Backoffice/SeasonController.php @@ -39,7 +39,39 @@ class SeasonController extends AbstractController name: 'tvdt_backoffice_season', requirements: ['seasonCode' => self::SEASON_CODE_REGEX], )] - public function index(Season $season, Request $request): Response + public function index(Season $season): Response + { + return $this->render('backoffice/season.html.twig', [ + 'season' => $season, + 'activeTab' => 'tests', + 'template' => 'backoffice/season/tab_tests.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/candidates', + name: 'tvdt_backoffice_season_candidates', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + priority: 10, + )] + public function candidatesTab(Season $season): Response + { + return $this->render('backoffice/season.html.twig', [ + 'season' => $season, + 'activeTab' => 'candidates', + 'template' => 'backoffice/season/tab_candidates.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/settings', + name: 'tvdt_backoffice_season_settings', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + priority: 10, + )] + public function settingsTab(Season $season, Request $request): Response { $form = $this->createForm(SettingsForm::class, $season->settings); @@ -47,11 +79,15 @@ class SeasonController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $this->em->flush(); + + return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]); } return $this->render('backoffice/season.html.twig', [ 'season' => $season, 'form' => $form, + 'activeTab' => 'settings', + 'template' => 'backoffice/season/tab_settings.html.twig', ]); } diff --git a/src/DataFixtures/DevFixtures.php b/src/DataFixtures/DevFixtures.php new file mode 100644 index 0000000..4d1dce2 --- /dev/null +++ b/src/DataFixtures/DevFixtures.php @@ -0,0 +1,35 @@ +email = 'admin@tijdvoordetest.nl'; + $user->password = $this->passwordHasher->hashPassword($user, '12345678'); + $user->roles = ['ROLE_ADMIN']; + + $manager->persist($user); + + $manager->flush(); + } +} diff --git a/src/DataFixtures/KrtekFixtures.php b/src/DataFixtures/KrtekFixtures.php index 2fe95b5..608b31c 100644 --- a/src/DataFixtures/KrtekFixtures.php +++ b/src/DataFixtures/KrtekFixtures.php @@ -7,9 +7,14 @@ namespace Tvdt\DataFixtures; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Persistence\ObjectManager; +use Safe\DateTimeImmutable; use Tvdt\Entity\Answer; +use Tvdt\Entity\BankAnswer; +use Tvdt\Entity\BankQuestion; +use Tvdt\Entity\BankQuestionUsage; use Tvdt\Entity\Candidate; use Tvdt\Entity\Question; +use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; use Tvdt\Entity\SeasonSettings; @@ -18,6 +23,16 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface { public const string KRTEK_SEASON = 'krtek-seaspm'; + public const string KRTEK_QUIZ_1 = 'krtek-quiz-1'; + + public const string KRTEK_QUIZ_2 = 'krtek-quiz-2'; + + public const string BANK_QUESTION_REUSABLE = 'bank-question-reusable'; + + public const string BANK_QUESTION_USED = 'bank-question-used'; + + public const string BANK_QUESTION_UNUSED = 'bank-question-unused'; + public static function getGroups(): array { return ['test', 'dev']; @@ -47,16 +62,64 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface $quiz1 = $this->createQuiz1($season); $season->addQuiz($quiz1); $season->activeQuiz = $quiz1; - $season->addQuiz($this->createQuiz2($season)); + + $quiz1->finalizedAt = new DateTimeImmutable(); + $quiz2 = $this->createQuiz2($season); + $season->addQuiz($quiz2); \assert($season->settings instanceof SeasonSettings); $season->settings->confirmAnswers = true; $season->settings->showNumbers = true; + $this->createQuestionBank($season, $quiz2); + $manager->flush(); $this->addReference(self::KRTEK_SEASON, $season); + $this->addReference(self::KRTEK_QUIZ_1, $quiz1); + $this->addReference(self::KRTEK_QUIZ_2, $quiz2); + } + + private function createQuestionBank(Season $season, Quiz $usedInQuiz): void + { + $location = new QuestionLabel('Locatie'); + $season->addQuestionLabel($location); + $finale = new QuestionLabel('Finale'); + $season->addQuestionLabel($finale); + + $reusable = new BankQuestion(); + $reusable->question = 'Wie is de Krtek?'; + $reusable->reusable = true; + $reusable->addLabel($finale); + $reusable->addAnswer(new BankAnswer('Claudia', true)); + $reusable->addAnswer(new BankAnswer('Eelco')); + $reusable->addAnswer(new BankAnswer('Elise')); + + $season->addBankQuestion($reusable); + + $used = new BankQuestion(); + $used->question = 'Waar sliep de Krtek?'; + $used->addLabel($location); + $used->addAnswer(new BankAnswer('Boven', true)); + $used->addAnswer(new BankAnswer('Beneden')); + $used->addUsage(new BankQuestionUsage($used, $usedInQuiz)); + + $season->addBankQuestion($used); + + $unused = new BankQuestion(); + $unused->question = 'Wat at de Krtek als ontbijt?'; + $unused->addLabel($location); + $unused->addLabel($finale); + $unused->addAnswer(new BankAnswer('Brood', true)); + $unused->addAnswer(new BankAnswer('Yoghurt')); + $unused->addAnswer(new BankAnswer('Niks')); + + $season->addBankQuestion($unused); + + $this->addReference(self::BANK_QUESTION_REUSABLE, $reusable); + $this->addReference(self::BANK_QUESTION_USED, $used); + $this->addReference(self::BANK_QUESTION_UNUSED, $unused); } private function createQuiz1(Season $season): Quiz diff --git a/src/Entity/BankAnswer.php b/src/Entity/BankAnswer.php new file mode 100644 index 0000000..c06e49d --- /dev/null +++ b/src/Entity/BankAnswer.php @@ -0,0 +1,39 @@ + 0])] + public int $ordering = 0; + + #[ORM\JoinColumn(nullable: false)] + #[ORM\ManyToOne(inversedBy: 'answers')] + public BankQuestion $bankQuestion; + + public function __construct( + #[ORM\Column(length: 255)] + public string $text, + #[ORM\Column] + public bool $isRightAnswer = false, + ) {} + + public function __toString(): string + { + return $this->text; + } +} diff --git a/src/Entity/BankQuestion.php b/src/Entity/BankQuestion.php new file mode 100644 index 0000000..ec54c05 --- /dev/null +++ b/src/Entity/BankQuestion.php @@ -0,0 +1,129 @@ + false])] + public bool $reusable = false; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: QuestionLabel::class, inversedBy: 'bankQuestions')] + public private(set) Collection $labels; + + /** @var Collection */ + #[Assert\Count(min: 2, minMessage: 'A question needs at least two answers')] + #[ORM\OneToMany(targetEntity: BankAnswer::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)] + #[ORM\OrderBy(['ordering' => 'ASC'])] + public private(set) Collection $answers; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: BankQuestionUsage::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)] + public private(set) Collection $usages; + + public function __construct() + { + $this->labels = new ArrayCollection(); + $this->answers = new ArrayCollection(); + $this->usages = new ArrayCollection(); + } + + public function addAnswer(BankAnswer $answer): static + { + if (!$this->answers->contains($answer)) { + $this->answers->add($answer); + $answer->bankQuestion = $this; + } + + return $this; + } + + public function removeAnswer(BankAnswer $answer): static + { + $this->answers->removeElement($answer); + + return $this; + } + + public function addLabel(QuestionLabel $label): static + { + if (!$this->labels->contains($label)) { + $this->labels->add($label); + } + + return $this; + } + + public function removeLabel(QuestionLabel $label): static + { + $this->labels->removeElement($label); + + return $this; + } + + public function addUsage(BankQuestionUsage $usage): static + { + if (!$this->usages->contains($usage)) { + $this->usages->add($usage); + } + + return $this; + } + + public function isUsed(): bool + { + return !$this->usages->isEmpty(); + } + + public function canBeAssigned(): bool + { + return $this->reusable || !$this->isUsed(); + } + + public function isUsedInQuiz(Quiz $quiz): bool + { + return $this->usages->exists(static fn (int $key, BankQuestionUsage $usage): bool => $usage->quiz === $quiz); + } + + #[Assert\Callback] + public function validateAnswers(ExecutionContextInterface $context): void + { + $correctAnswers = $this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count(); + + if (1 !== $correctAnswers) { + $context->buildViolation('A question must have exactly one correct answer') + ->atPath('answers') + ->addViolation(); + } + } + + public function __toString(): string + { + return $this->question ?? ''; + } +} diff --git a/src/Entity/BankQuestionUsage.php b/src/Entity/BankQuestionUsage.php new file mode 100644 index 0000000..3cbf138 --- /dev/null +++ b/src/Entity/BankQuestionUsage.php @@ -0,0 +1,36 @@ + */ + #[ORM\ManyToMany(targetEntity: BankQuestion::class, mappedBy: 'labels')] + public private(set) Collection $bankQuestions; + + public function __construct( + #[ORM\Column(length: 64)] + public string $name, + ) { + $this->bankQuestions = new ArrayCollection(); + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Entity/Quiz.php b/src/Entity/Quiz.php index 056b7d3..48c57b3 100644 --- a/src/Entity/Quiz.php +++ b/src/Entity/Quiz.php @@ -6,6 +6,7 @@ namespace Tvdt\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Uid\Uuid; @@ -40,6 +41,9 @@ class Quiz #[ORM\Column(nullable: false, options: ['default' => 1])] public int $dropouts = 1; + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)] + public ?\DateTimeImmutable $finalizedAt = null; + /** @var Collection */ #[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)] #[ORM\OrderBy(['createdAt' => 'DESC'])] @@ -62,6 +66,29 @@ class Quiz return $this; } + public function isFinalized(): bool + { + return $this->finalizedAt instanceof \DateTimeImmutable; + } + + public function hasStartedCandidates(): bool + { + return $this->candidateData->exists(static fn (int $key, QuizCandidate $quizCandidate): bool => $quizCandidate->started instanceof \DateTimeImmutable); + } + + /** + * A locked quiz can no longer be altered: it is either explicitly + * finalized or a candidate has already started filling it in. + */ + public function isLocked(): bool + { + if ($this->isFinalized()) { + return true; + } + + return $this->hasStartedCandidates(); + } + public function addElimination(Elimination $elimination): self { $this->eliminations->add($elimination); diff --git a/src/Entity/Season.php b/src/Entity/Season.php index 6426f61..3f28e66 100644 --- a/src/Entity/Season.php +++ b/src/Entity/Season.php @@ -51,12 +51,24 @@ class Season #[ORM\OneToOne(cascade: ['persist', 'remove'])] public ?SeasonSettings $settings = null; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: BankQuestion::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)] + #[ORM\OrderBy(['question' => 'ASC'])] + public private(set) Collection $bankQuestions; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: QuestionLabel::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)] + #[ORM\OrderBy(['name' => 'ASC'])] + public private(set) Collection $questionLabels; + public function __construct() { $this->settings = new SeasonSettings(); $this->quizzes = new ArrayCollection(); $this->candidates = new ArrayCollection(); $this->owners = new ArrayCollection(); + $this->bankQuestions = new ArrayCollection(); + $this->questionLabels = new ArrayCollection(); } public function addQuiz(Quiz $quiz): static @@ -79,6 +91,26 @@ class Season return $this; } + public function addBankQuestion(BankQuestion $bankQuestion): static + { + if (!$this->bankQuestions->contains($bankQuestion)) { + $this->bankQuestions->add($bankQuestion); + $bankQuestion->season = $this; + } + + return $this; + } + + public function addQuestionLabel(QuestionLabel $questionLabel): static + { + if (!$this->questionLabels->contains($questionLabel)) { + $this->questionLabels->add($questionLabel); + $questionLabel->season = $this; + } + + return $this; + } + public function addOwner(User $owner): static { if (!$this->owners->contains($owner)) { diff --git a/src/Exception/BankQuestionAlreadyUsedException.php b/src/Exception/BankQuestionAlreadyUsedException.php new file mode 100644 index 0000000..dbbbe0d --- /dev/null +++ b/src/Exception/BankQuestionAlreadyUsedException.php @@ -0,0 +1,7 @@ + */ +class BankAnswerFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('text', TextType::class, [ + 'label' => false, + 'attr' => ['placeholder' => 'Answer', 'maxlength' => 255], + ]) + ->add('isRightAnswer', CheckboxType::class, [ + 'label' => 'Correct', + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => BankAnswer::class, + 'empty_data' => static fn (): BankAnswer => new BankAnswer(''), + ]); + } +} diff --git a/src/Form/BankQuestionFormType.php b/src/Form/BankQuestionFormType.php new file mode 100644 index 0000000..02d38eb --- /dev/null +++ b/src/Form/BankQuestionFormType.php @@ -0,0 +1,75 @@ + */ +class BankQuestionFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /** @var Season $season */ + $season = $options['season']; + + $builder + ->add('question', TextType::class, [ + 'label' => 'Question', + 'attr' => ['maxlength' => 255], + ]) + ->add('reusable', CheckboxType::class, [ + 'label' => 'Reusable', + 'required' => false, + 'label_attr' => ['class' => 'checkbox-switch'], + 'attr' => ['role' => 'switch', 'switch' => null], + ]) + ->add('labels', EntityType::class, [ + 'label' => 'Labels', + 'class' => QuestionLabel::class, + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository + ->createQueryBuilder('l') + ->where('l.season = :season') + ->orderBy('l.name', 'ASC') + ->setParameter('season', $season), + ]) + ->add('answers', CollectionType::class, [ + 'label' => 'Answers', + 'entry_type' => BankAnswerFormType::class, + 'entry_options' => ['label' => false], + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'prototype' => true, + ]) + ->add('save', SubmitType::class, [ + 'label' => 'Save', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => BankQuestion::class, + ]); + $resolver->setRequired('season'); + $resolver->setAllowedTypes('season', Season::class); + } +} diff --git a/src/Repository/BankQuestionRepository.php b/src/Repository/BankQuestionRepository.php new file mode 100644 index 0000000..f561ce7 --- /dev/null +++ b/src/Repository/BankQuestionRepository.php @@ -0,0 +1,43 @@ + */ +class BankQuestionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BankQuestion::class); + } + + /** @return list */ + public function findBySeason(Season $season, ?QuestionLabel $label = null): array + { + $queryBuilder = $this->createQueryBuilder('bq') + ->select('bq', 'ba', 'l', 'u', 'uq') + ->leftJoin('bq.answers', 'ba') + ->leftJoin('bq.labels', 'l') + ->leftJoin('bq.usages', 'u') + ->leftJoin('u.quiz', 'uq') + ->where('bq.season = :season') + ->orderBy('bq.question', 'ASC') + ->setParameter('season', $season); + + if ($label instanceof QuestionLabel) { + $queryBuilder + ->andWhere(':label member of bq.labels') + ->setParameter('label', $label); + } + + /* @var list */ + return $queryBuilder->getQuery()->getResult(); + } +} diff --git a/src/Repository/QuestionLabelRepository.php b/src/Repository/QuestionLabelRepository.php new file mode 100644 index 0000000..97f143f --- /dev/null +++ b/src/Repository/QuestionLabelRepository.php @@ -0,0 +1,18 @@ + */ +class QuestionLabelRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, QuestionLabel::class); + } +} diff --git a/src/Repository/QuizRepository.php b/src/Repository/QuizRepository.php index e4a2b5d..893fe6e 100644 --- a/src/Repository/QuizRepository.php +++ b/src/Repository/QuizRepository.php @@ -12,6 +12,7 @@ use Safe\Exceptions\DatetimeException; use Symfony\Component\Uid\Uuid; use Tvdt\Dto\Result; use Tvdt\Entity\Quiz; +use Tvdt\Entity\Season; use Tvdt\Exception\ErrorClearingQuizException; /** @extends ServiceEntityRepository */ @@ -22,6 +23,29 @@ class QuizRepository extends ServiceEntityRepository parent::__construct($registry, Quiz::class); } + /** + * Quizzes of the season that can still receive bank questions: + * not finalized and not started by any candidate. + * + * @return list + */ + public function findAssignableForSeason(Season $season): array + { + /* @var list */ + return $this->getEntityManager()->createQuery(<<setParameter('season', $season) + ->getResult(); + } + /** @throws ErrorClearingQuizException */ public function clearQuiz(Quiz $quiz): void { diff --git a/src/Security/Voter/SeasonVoter.php b/src/Security/Voter/SeasonVoter.php index 85fa9e2..4f1ed80 100644 --- a/src/Security/Voter/SeasonVoter.php +++ b/src/Security/Voter/SeasonVoter.php @@ -8,14 +8,16 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Tvdt\Entity\Answer; +use Tvdt\Entity\BankQuestion; use Tvdt\Entity\Candidate; use Tvdt\Entity\Elimination; use Tvdt\Entity\Question; +use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; use Tvdt\Entity\User; -/** @extends Voter */ +/** @extends Voter */ final class SeasonVoter extends Voter { public const string EDIT = 'SEASON_EDIT'; @@ -24,15 +26,19 @@ final class SeasonVoter extends Voter public const string DELETE = 'SEASON_DELETE'; + public const string MODIFY_QUIZ_CONTENT = 'QUIZ_MODIFY_CONTENT'; + protected function supports(string $attribute, mixed $subject): bool { - return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true) + return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION, self::MODIFY_QUIZ_CONTENT], true) && ( $subject instanceof Answer + || $subject instanceof BankQuestion || $subject instanceof Candidate || $subject instanceof Elimination || $subject instanceof Season || $subject instanceof Question + || $subject instanceof QuestionLabel || $subject instanceof Quiz ); } @@ -44,19 +50,36 @@ final class SeasonVoter extends Voter return false; } - if ($user->isAdmin) { - return true; - } - $season = match (true) { $subject instanceof Answer => $subject->question->quiz->season, $subject instanceof Elimination, $subject instanceof Question => $subject->quiz->season, + $subject instanceof BankQuestion, $subject instanceof Candidate, + $subject instanceof QuestionLabel, $subject instanceof Quiz => $subject->season, $subject instanceof Season => $subject, }; + if (self::MODIFY_QUIZ_CONTENT === $attribute) { + $quiz = match (true) { + $subject instanceof Answer => $subject->question->quiz, + $subject instanceof Question => $subject->quiz, + $subject instanceof Quiz => $subject, + default => null, + }; + + if (!$quiz instanceof Quiz || $quiz->isLocked()) { + return false; + } + + return $user->isAdmin || $season->isOwner($user); + } + + if ($user->isAdmin) { + return true; + } + return match ($attribute) { self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user), default => false, diff --git a/src/Service/QuestionBankService.php b/src/Service/QuestionBankService.php new file mode 100644 index 0000000..7aac3e2 --- /dev/null +++ b/src/Service/QuestionBankService.php @@ -0,0 +1,61 @@ +season !== $quiz->season) { + throw new \InvalidArgumentException('Bank question and quiz belong to different seasons'); + } + + if ($quiz->isLocked()) { + throw new QuizLockedException(); + } + + if (!$bankQuestion->canBeAssigned() || $bankQuestion->isUsedInQuiz($quiz)) { + throw new BankQuestionAlreadyUsedException(); + } + + $maxOrdering = 0; + foreach ($quiz->questions as $existingQuestion) { + $maxOrdering = max($maxOrdering, $existingQuestion->ordering); + } + + $question = new Question(); + $question->question = $bankQuestion->question; + $question->ordering = $maxOrdering + 1; + + foreach ($bankQuestion->answers as $bankAnswer) { + $answer = new Answer($bankAnswer->text, $bankAnswer->isRightAnswer); + $answer->ordering = $bankAnswer->ordering; + $question->addAnswer($answer); + } + + $quiz->addQuestion($question); + $bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz)); + + $this->entityManager->persist($question); + $this->entityManager->flush(); + } +} diff --git a/templates/backoffice/question_bank/form.html.twig b/templates/backoffice/question_bank/form.html.twig new file mode 100644 index 0000000..184d5fd --- /dev/null +++ b/templates/backoffice/question_bank/form.html.twig @@ -0,0 +1,55 @@ +{% extends 'backoffice/base.html.twig' %} + +{% macro answer_row(answerForm) %} +
+
{{ form_widget(answerForm.text) }}
+
+ {{ form_widget(answerForm.isRightAnswer, {attr: {class: 'form-check-input'}}) }} + {{ form_label(answerForm.isRightAnswer, null, {label_attr: {class: 'form-check-label'}}) }} +
+ +
+{% endmacro %} + +{% block title %}{{ parent() }}{{ 'Question bank'|trans }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block body %} +
+
+

{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}

+ + {{ form_start(form) }} + {{ form_row(form.question) }} + {{ form_row(form.reusable) }} + {{ form_row(form.labels) }} + +
+ {{ form_label(form.answers) }} + {{ form_errors(form.answers) }} +
+ {% for answerForm in form.answers %} + {{ _self.answer_row(answerForm) }} + {% endfor %} +
+ {% do form.answers.setRendered %} + +
+ + {{ form_end(form) }} +
+
+{% endblock body %} diff --git a/templates/backoffice/quiz/tab_overview.html.twig b/templates/backoffice/quiz/tab_overview.html.twig index adf01be..1bed985 100644 --- a/templates/backoffice/quiz/tab_overview.html.twig +++ b/templates/backoffice/quiz/tab_overview.html.twig @@ -23,7 +23,16 @@ {% endmacro %}
-

{{ 'Quick actions'|trans }}

+

+ {{ 'Quick actions'|trans }} + {% if quiz.isFinalized %} + {{ 'Finalized'|trans }} + {% elseif quiz.isLocked %} + {{ 'Locked (answers given)'|trans }} + {% else %} + {{ 'Draft'|trans }} + {% endif %} +

{% if quiz is same as (season.activeQuiz) %} @@ -36,11 +45,28 @@ {% else %}
-
{% endif %} + {% if not quiz.isFinalized %} +
+ + +
+ {% elseif not quiz.hasStartedCandidates and quiz is not same as (season.activeQuiz) %} +
+ + +
+ {% endif %} diff --git a/templates/backoffice/season.html.twig b/templates/backoffice/season.html.twig index 166931d..326dc4c 100644 --- a/templates/backoffice/season.html.twig +++ b/templates/backoffice/season.html.twig @@ -11,39 +11,24 @@ {% endblock %} {% block body %} -

{{ 'Season'|trans }}: {{ season.name }}

-
-
-
-

{{ 'Quizzes'|trans }}

- {{ 'Add'|trans }} -
-
- {% for quiz in season.quizzes %} - {{ quiz.name }} - {% else %} - {{ 'No quizzes'|trans }} - {% endfor %} -
-
-
-
-

{{ 'Candidates'|trans }}

- {{ 'Add Candidate'|trans }} - -
-
    - {% for candidate in season.candidates %} -
  • {{ candidate.name }}
  • {% endfor %} -
+ {% set tabs = [ + {id: 'tests', label: 'Quizzes'|trans, route: 'tvdt_backoffice_season'}, + {id: 'question-bank', label: 'Question bank'|trans, route: 'tvdt_backoffice_question_bank'}, + {id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_season_candidates'}, + {id: 'settings', label: 'Settings'|trans, route: 'tvdt_backoffice_season_settings'}, + ] %} -
-

{{ 'Settings'|trans }}

-
- {{ form(form) }} -
+

{{ 'Season'|trans }}: {{ season.name }}

+ +
+ {{ include(template) }}
{% endblock body %} diff --git a/templates/backoffice/season/tab_candidates.html.twig b/templates/backoffice/season/tab_candidates.html.twig new file mode 100644 index 0000000..6681741 --- /dev/null +++ b/templates/backoffice/season/tab_candidates.html.twig @@ -0,0 +1,17 @@ +
+
+
+

{{ 'Candidates'|trans }}

+ {{ 'Add Candidate'|trans }} + +
+
    + {% for candidate in season.candidates %} +
  • {{ candidate.name }}
  • + {% else %} + {{ 'No candidates'|trans }} + {% endfor %} +
+
+
diff --git a/templates/backoffice/season/tab_question_bank.html.twig b/templates/backoffice/season/tab_question_bank.html.twig new file mode 100644 index 0000000..7351199 --- /dev/null +++ b/templates/backoffice/season/tab_question_bank.html.twig @@ -0,0 +1,109 @@ +
+

{{ 'Question bank'|trans }}

+ {{ 'Add'|trans }} +
+ +
+ {{ 'All'|trans }} + {% for label in season.questionLabels %} + + {{ label.name }} +
+ + +
+
+ {% endfor %} +
+ + + +
+
+ + + + + + + + + + + + + {% for bankQuestion in bankQuestions %} + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ 'Question'|trans }}{{ 'Labels'|trans }}{{ 'Reusable'|trans }}{{ 'Used in'|trans }}
{{ bankQuestion.question }} + {% for label in bankQuestion.labels %} + {{ label.name }} + {% endfor %} + + {% if bankQuestion.reusable %} + {{ 'Reusable'|trans }} + {% endif %} + + {{ bankQuestion.usages|map(usage => usage.quiz.name)|join(', ') }} + +
+ {% if bankQuestion.canBeAssigned and assignableQuizzes|length > 0 %} +
+ + + +
+ {% endif %} + {{ 'Edit'|trans }} + +
+ + +
{{ 'No questions in the question bank yet'|trans }}
diff --git a/templates/backoffice/season/tab_settings.html.twig b/templates/backoffice/season/tab_settings.html.twig new file mode 100644 index 0000000..8d638a7 --- /dev/null +++ b/templates/backoffice/season/tab_settings.html.twig @@ -0,0 +1,6 @@ +
+
+

{{ 'Settings'|trans }}

+ {{ form(form) }} +
+
diff --git a/templates/backoffice/season/tab_tests.html.twig b/templates/backoffice/season/tab_tests.html.twig new file mode 100644 index 0000000..2e8c00b --- /dev/null +++ b/templates/backoffice/season/tab_tests.html.twig @@ -0,0 +1,22 @@ +
+
+
+

{{ 'Quizzes'|trans }}

+ {{ 'Add'|trans }} +
+
+ {% for quiz in season.quizzes %} + + {{ quiz.name }} + {% if quiz.isFinalized %} + {{ 'Finalized'|trans }} + {% endif %} + + {% else %} + {{ 'No quizzes'|trans }} + {% endfor %} +
+
+
diff --git a/tests/Controller/Backoffice/QueryCountTest.php b/tests/Controller/Backoffice/QueryCountTest.php new file mode 100644 index 0000000..5df4840 --- /dev/null +++ b/tests/Controller/Backoffice/QueryCountTest.php @@ -0,0 +1,77 @@ +client = self::createClient(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); + $this->assertInstanceOf(User::class, $user); + $this->client->loginUser($user); + } + + /** @return \Generator */ + public static function pageProvider(): \Generator + { + yield 'season tests tab' => ['/backoffice/season/krtek', 4]; + yield 'question bank tab' => ['/backoffice/season/krtek/question-bank', 6]; + yield 'candidates tab' => ['/backoffice/season/krtek/candidates', 4]; + yield 'settings tab' => ['/backoffice/season/krtek/settings', 4]; + yield 'question bank new' => ['/backoffice/season/krtek/question-bank/new', 4]; + yield 'quiz overview' => ['/backoffice/season/krtek/quiz/%quiz%/overview', 5]; + yield 'quiz result' => ['/backoffice/season/krtek/quiz/%quiz%/result', 6]; + yield 'quiz candidates list' => ['/backoffice/season/krtek/quiz/%quiz%/candidates-list', 7]; + } + + #[DataProvider('pageProvider')] + public function testPageStaysWithinQueryBudget(string $url, int $maxQueries): void + { + if (str_contains($url, '%quiz%')) { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']); + $this->assertInstanceOf(Quiz::class, $quiz); + $url = str_replace('%quiz%', (string) $quiz->id, $url); + } + + // Warm up so boot/setup queries do not pollute the profiled request + $this->client->request(Request::METHOD_GET, '/backoffice'); + + $this->client->enableProfiler(); + $this->client->request(Request::METHOD_GET, $url); + $this->assertResponseIsSuccessful(); + + $profile = $this->client->getProfile(); + $this->assertInstanceOf(Profile::class, $profile); + + $collector = $profile->getCollector('db'); + $this->assertInstanceOf(DoctrineDataCollector::class, $collector); + $queries = $collector->getQueries()['default'] ?? []; + + $sql = implode("\n", array_map(static fn (array $query): string => $query['sql'], $queries)); + $this->assertLessThanOrEqual($maxQueries, \count($queries), \sprintf("Query budget exceeded for %s:\n%s", $url, $sql)); + } +} diff --git a/tests/Controller/Backoffice/QuestionBankControllerTest.php b/tests/Controller/Backoffice/QuestionBankControllerTest.php new file mode 100644 index 0000000..4d46fec --- /dev/null +++ b/tests/Controller/Backoffice/QuestionBankControllerTest.php @@ -0,0 +1,326 @@ +client = self::createClient(); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + private function loginAsOwner(): void + { + $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); + $this->assertInstanceOf(User::class, $user); + $this->client->loginUser($user); + } + + private function getBankQuestion(string $question): BankQuestion + { + $bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]); + $this->assertInstanceOf(BankQuestion::class, $bankQuestion); + + return $bankQuestion; + } + + private function getQuizByName(string $name): Quiz + { + $quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]); + $this->assertInstanceOf(Quiz::class, $quiz); + + return $quiz; + } + + private function getCsrfToken(string $formActionContains): string + { + $crawler = $this->client->getCrawler(); + $input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains)); + $this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains)); + + return (string) $input->first()->attr('value'); + } + + public function testIndexListsBankQuestions(): void + { + $this->loginAsOwner(); + $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('body', 'Wie is de Krtek?'); + $this->assertSelectorTextContains('body', 'Waar sliep de Krtek?'); + $this->assertSelectorTextContains('body', 'Wat at de Krtek als ontbijt?'); + } + + public function testIndexFiltersByLabel(): void + { + $this->loginAsOwner(); + $label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']); + $this->assertInstanceOf(QuestionLabel::class, $label); + + $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank?label='.$label->id); + + $this->assertResponseIsSuccessful(); + $body = $crawler->filter('tbody')->text(); + $this->assertStringContainsString('Waar sliep de Krtek?', $body); + $this->assertStringNotContainsString('Wie is de Krtek?', $body); + } + + public function testNonOwnerIsDenied(): void + { + $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); + $this->assertInstanceOf(User::class, $user); + $this->client->loginUser($user); + + $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + + $this->assertResponseStatusCodeSame(403); + } + + public function testCreateBankQuestion(): void + { + $this->loginAsOwner(); + $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new'); + $this->assertResponseIsSuccessful(); + + $token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value'); + + $this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/new', [ + 'bank_question_form' => [ + 'question' => 'Wat is de lievelingskleur van de Krtek?', + 'reusable' => '1', + 'answers' => [ + ['text' => 'Rood', 'isRightAnswer' => '1'], + ['text' => 'Blauw'], + ], + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->entityManager->clear(); + $bankQuestion = $this->getBankQuestion('Wat is de lievelingskleur van de Krtek?'); + $this->assertTrue($bankQuestion->reusable); + $this->assertCount(2, $bankQuestion->answers); + $this->assertSame('Rood', (string) $bankQuestion->answers->first()); + } + + public function testCreateRefusedWithoutCorrectAnswer(): void + { + $this->loginAsOwner(); + $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new'); + $token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value'); + + $this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/new', [ + 'bank_question_form' => [ + 'question' => 'Vraag zonder goed antwoord', + 'answers' => [ + ['text' => 'Een'], + ['text' => 'Twee'], + ], + '_token' => $token, + ], + ]); + + $this->assertResponseIsUnprocessable(); + $this->assertNotInstanceOf(BankQuestion::class, $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord'])); + } + + public function testEditBankQuestion(): void + { + $this->loginAsOwner(); + $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); + + $url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id); + $crawler = $this->client->request(Request::METHOD_GET, $url); + $this->assertResponseIsSuccessful(); + $token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value'); + + $this->client->request(Request::METHOD_POST, $url, [ + 'bank_question_form' => [ + 'question' => 'Wat dronk de Krtek als ontbijt?', + 'answers' => [ + ['text' => 'Koffie', 'isRightAnswer' => '1'], + ['text' => 'Thee'], + ], + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->entityManager->clear(); + $bankQuestion = $this->getBankQuestion('Wat dronk de Krtek als ontbijt?'); + $this->assertFalse($bankQuestion->reusable); + $this->assertCount(2, $bankQuestion->answers); + } + + public function testDeleteUsedBankQuestionLeavesQuizIntact(): void + { + $this->loginAsOwner(); + $bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?'); + $quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count(); + + $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + $token = $this->getCsrfToken(\sprintf('%s/delete', $bankQuestion->id)); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [ + '_token' => $token, + ]); + + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->entityManager->clear(); + $this->assertNotInstanceOf(BankQuestion::class, $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Waar sliep de Krtek?'])); + $this->assertCount($quiz2QuestionCount, $this->getQuizByName('Quiz 2')->questions); + } + + public function testAssignCopiesQuestionIntoQuiz(): void + { + $this->loginAsOwner(); + $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); + $quiz = $this->getQuizByName('Quiz 2'); + $questionCount = $quiz->questions->count(); + + $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + $token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id)); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [ + '_token' => $token, + 'quiz' => (string) $quiz->id, + ]); + + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->entityManager->clear(); + $quiz = $this->getQuizByName('Quiz 2'); + $this->assertCount($questionCount + 1, $quiz->questions); + + $copiedQuestion = null; + $maxOrdering = 0; + foreach ($quiz->questions as $question) { + $maxOrdering = max($maxOrdering, $question->ordering); + if ('Wat at de Krtek als ontbijt?' === $question->question) { + $copiedQuestion = $question; + } + } + + $this->assertInstanceOf(Question::class, $copiedQuestion); + $this->assertSame($maxOrdering, $copiedQuestion->ordering); + $this->assertCount(3, $copiedQuestion->answers); + + $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); + $this->assertTrue($bankQuestion->isUsed()); + $this->assertFalse($bankQuestion->canBeAssigned()); + } + + public function testAssignUsedNonReusableQuestionIsRefused(): void + { + $this->loginAsOwner(); + $bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?'); + $quiz = $this->getQuizByName('Quiz 2'); + $questionCount = $quiz->questions->count(); + + $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + + // The assign form is not rendered for used questions, so post with another form's token + $token = $this->getCsrfToken('/assign'); + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [ + '_token' => $token, + 'quiz' => (string) $quiz->id, + ]); + + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->entityManager->clear(); + $this->assertCount($questionCount, $this->getQuizByName('Quiz 2')->questions); + } + + public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void + { + $this->loginAsOwner(); + $bankQuestion = $this->getBankQuestion('Wie is de Krtek?'); + $quiz = $this->getQuizByName('Quiz 2'); + $questionCount = $quiz->questions->count(); + + $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + $token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id)); + + $url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id); + $this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]); + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]); + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->entityManager->clear(); + $this->assertCount($questionCount + 1, $this->getQuizByName('Quiz 2')->questions); + } + + public function testAssignIntoFinalizedQuizIsDenied(): void + { + $this->loginAsOwner(); + $bankQuestion = $this->getBankQuestion('Wie is de Krtek?'); + $finalizedQuiz = $this->getQuizByName('Quiz 1'); + $this->assertTrue($finalizedQuiz->isFinalized()); + + $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + $token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id)); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [ + '_token' => $token, + 'quiz' => (string) $finalizedQuiz->id, + ]); + + $this->assertResponseStatusCodeSame(403); + } + + public function testAddAndDeleteLabel(): void + { + $this->loginAsOwner(); + $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + $token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value'); + + $this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/labels', [ + '_token' => $token, + 'name' => 'Opdracht', + ]); + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->entityManager->clear(); + $label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Opdracht']); + $this->assertInstanceOf(QuestionLabel::class, $label); + + $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); + $deleteToken = $this->getCsrfToken(\sprintf('labels/%s/delete', $label->id)); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->id), [ + '_token' => $deleteToken, + ]); + $this->assertResponseRedirects('/backoffice/season/krtek/question-bank'); + + $this->entityManager->clear(); + $this->assertNotInstanceOf(QuestionLabel::class, $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Opdracht'])); + } +} diff --git a/tests/Controller/Backoffice/QuizFinalizeTest.php b/tests/Controller/Backoffice/QuizFinalizeTest.php new file mode 100644 index 0000000..4ec46ff --- /dev/null +++ b/tests/Controller/Backoffice/QuizFinalizeTest.php @@ -0,0 +1,215 @@ +client = self::createClient(); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); + $this->assertInstanceOf(User::class, $user); + $this->client->loginUser($user); + } + + private function getQuizByName(string $name): Quiz + { + $quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]); + $this->assertInstanceOf(Quiz::class, $quiz); + + return $quiz; + } + + private function getKrtekSeason(): Season + { + $season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']); + $this->assertInstanceOf(Season::class, $season); + + return $season; + } + + private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string + { + $crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); + $this->assertResponseIsSuccessful(); + + $input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains)); + $this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains)); + + return (string) $input->first()->attr('value'); + } + + public function testFinalizeSetsFinalizedAt(): void + { + $quiz = $this->getQuizByName('Quiz 2'); + $this->assertFalse($quiz->isFinalized()); + + $token = $this->getCsrfTokenFromOverview($quiz, '/finalize'); + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/finalize', $quiz->id), ['_token' => $token]); + + $this->assertResponseRedirects(); + + $this->entityManager->clear(); + $this->assertTrue($this->getQuizByName('Quiz 2')->isFinalized()); + } + + public function testFinalizeRefusedWhenQuizHasErrors(): void + { + $season = $this->getKrtekSeason(); + + $invalidQuiz = new Quiz(); + $invalidQuiz->name = 'Invalid Quiz'; + + $question = new Question(); + $question->question = 'Vraag zonder goed antwoord'; + $question->ordering = 1; + $question->addAnswer(new Answer('Fout')); + $question->addAnswer(new Answer('Ook fout')); + + $invalidQuiz->addQuestion($question); + $season->addQuiz($invalidQuiz); + $this->entityManager->persist($invalidQuiz); + $this->entityManager->flush(); + + // Token intention is shared, so any quiz overview provides it + $token = $this->getCsrfTokenFromOverview($invalidQuiz, '/finalize'); + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/finalize', $invalidQuiz->id), ['_token' => $token]); + + $this->assertResponseRedirects(); + + $this->entityManager->clear(); + $this->assertFalse($this->getQuizByName('Invalid Quiz')->isFinalized()); + } + + public function testEnableRefusedWhenNotFinalized(): void + { + $quiz = $this->getQuizByName('Quiz 2'); + $this->assertFalse($quiz->isFinalized()); + + $token = $this->getCsrfTokenFromOverview($quiz, '/enable'); + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/enable', $quiz->id), ['_token' => $token]); + + $this->assertResponseRedirects(); + + $this->entityManager->clear(); + $season = $this->getKrtekSeason(); + $this->assertInstanceOf(Quiz::class, $season->activeQuiz); + $this->assertSame('Quiz 1', $season->activeQuiz->name); + } + + public function testEnableAllowedWhenFinalized(): void + { + $quiz = $this->getQuizByName('Quiz 2'); + $quiz->finalizedAt = new DateTimeImmutable(); + + $this->entityManager->flush(); + + $token = $this->getCsrfTokenFromOverview($quiz, '/enable'); + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/enable', $quiz->id), ['_token' => $token]); + + $this->assertResponseRedirects(); + + $this->entityManager->clear(); + $season = $this->getKrtekSeason(); + $this->assertInstanceOf(Quiz::class, $season->activeQuiz); + $this->assertSame('Quiz 2', $season->activeQuiz->name); + } + + public function testUnfinalize(): void + { + $quiz = $this->getQuizByName('Quiz 2'); + $quiz->finalizedAt = new DateTimeImmutable(); + + $this->entityManager->flush(); + + $token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize'); + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/unfinalize', $quiz->id), ['_token' => $token]); + + $this->assertResponseRedirects(); + + $this->entityManager->clear(); + $this->assertFalse($this->getQuizByName('Quiz 2')->isFinalized()); + } + + public function testUnfinalizeRefusedWhenQuizIsActive(): void + { + // Quiz 1 is finalized and active in the fixtures; scrape a token from Quiz 2 (same intention) + $quiz2 = $this->getQuizByName('Quiz 2'); + $quiz2->finalizedAt = new DateTimeImmutable(); + + $this->entityManager->flush(); + $token = $this->getCsrfTokenFromOverview($quiz2, '/unfinalize'); + + $quiz1 = $this->getQuizByName('Quiz 1'); + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/unfinalize', $quiz1->id), ['_token' => $token]); + + $this->assertResponseRedirects(); + + $this->entityManager->clear(); + $this->assertTrue($this->getQuizByName('Quiz 1')->isFinalized()); + } + + public function testUnfinalizeRefusedWhenCandidatesStarted(): void + { + $quiz = $this->getQuizByName('Quiz 2'); + $quiz->finalizedAt = new DateTimeImmutable(); + + $this->entityManager->flush(); + + // Scrape the token before a candidate starts, since the button disappears afterwards + $token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize'); + + $candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']); + $this->assertInstanceOf(Candidate::class, $candidate); + $quizCandidate = new QuizCandidate($quiz, $candidate); + $quizCandidate->started = new DateTimeImmutable(); + + $this->entityManager->persist($quizCandidate); + $this->entityManager->flush(); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/unfinalize', $quiz->id), ['_token' => $token]); + + $this->assertResponseRedirects(); + + $this->entityManager->clear(); + $this->assertTrue($this->getQuizByName('Quiz 2')->isFinalized()); + } + + public function testClearQuizResetsFinalization(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + $this->assertTrue($quiz->isFinalized()); + + $token = $this->getCsrfTokenFromOverview($quiz, '/clear'); + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/clear', $quiz->id), ['_token' => $token]); + + $this->assertResponseRedirects(); + + $this->entityManager->clear(); + $this->assertFalse($this->getQuizByName('Quiz 1')->isFinalized()); + } +} diff --git a/tests/Repository/BankQuestionRepositoryTest.php b/tests/Repository/BankQuestionRepositoryTest.php new file mode 100644 index 0000000..6326abd --- /dev/null +++ b/tests/Repository/BankQuestionRepositoryTest.php @@ -0,0 +1,53 @@ +bankQuestionRepository = self::getContainer()->get(BankQuestionRepository::class); + } + + public function testFindBySeasonReturnsAllQuestions(): void + { + $season = $this->getSeasonByCode('krtek'); + + $bankQuestions = $this->bankQuestionRepository->findBySeason($season); + + $this->assertCount(3, $bankQuestions); + } + + public function testFindBySeasonFiltersByLabel(): void + { + $season = $this->getSeasonByCode('krtek'); + $label = $this->entityManager->getRepository(QuestionLabel::class) + ->findOneBy(['season' => $season, 'name' => 'Locatie']); + $this->assertInstanceOf(QuestionLabel::class, $label); + + $bankQuestions = $this->bankQuestionRepository->findBySeason($season, $label); + + $this->assertCount(2, $bankQuestions); + foreach ($bankQuestions as $bankQuestion) { + $this->assertTrue($bankQuestion->labels->contains($label)); + } + } + + public function testFindBySeasonIgnoresOtherSeasons(): void + { + $season = $this->getSeasonByCode('bbbbb'); + + $this->assertCount(0, $this->bankQuestionRepository->findBySeason($season)); + } +} diff --git a/tests/Security/Voter/SeasonVoterTest.php b/tests/Security/Voter/SeasonVoterTest.php index e882af7..5fdc719 100644 --- a/tests/Security/Voter/SeasonVoterTest.php +++ b/tests/Security/Voter/SeasonVoterTest.php @@ -12,9 +12,11 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\User\UserInterface; use Tvdt\Entity\Answer; +use Tvdt\Entity\BankQuestion; use Tvdt\Entity\Candidate; use Tvdt\Entity\Elimination; use Tvdt\Entity\Question; +use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; use Tvdt\Entity\User; @@ -75,6 +77,61 @@ final class SeasonVoterTest extends TestCase $answer = self::createStub(Answer::class); $answer->question = $question; yield 'Answer' => [$answer]; + + $bankQuestion = self::createStub(BankQuestion::class); + $bankQuestion->season = $season; + yield 'BankQuestion' => [$bankQuestion]; + + $questionLabel = self::createStub(QuestionLabel::class); + $questionLabel->season = $season; + yield 'QuestionLabel' => [$questionLabel]; + } + + public function testModifyQuizContentGrantedOnUnlockedQuiz(): void + { + $season = self::createStub(Season::class); + $season->method('isOwner')->willReturn(true); + + $quiz = self::createStub(Quiz::class); + $quiz->season = $season; + $quiz->method('isLocked')->willReturn(false); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $this->seasonVoter->vote($this->token, $quiz, [SeasonVoter::MODIFY_QUIZ_CONTENT])); + } + + public function testModifyQuizContentDeniedOnLockedQuiz(): void + { + $season = self::createStub(Season::class); + $season->method('isOwner')->willReturn(true); + + $quiz = self::createStub(Quiz::class); + $quiz->season = $season; + $quiz->method('isLocked')->willReturn(true); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($this->token, $quiz, [SeasonVoter::MODIFY_QUIZ_CONTENT])); + } + + public function testModifyQuizContentDeniedOnLockedQuizForAdmin(): void + { + $user = new User(); + $user->roles = ['ROLE_ADMIN']; + + $token = $this->createStub(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + $quiz = self::createStub(Quiz::class); + $quiz->season = self::createStub(Season::class); + $quiz->method('isLocked')->willReturn(true); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($token, $quiz, [SeasonVoter::MODIFY_QUIZ_CONTENT])); + } + + public function testModifyQuizContentDeniedOnNonQuizSubject(): void + { + $season = self::createStub(Season::class); + $season->method('isOwner')->willReturn(true); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($this->token, $season, [SeasonVoter::MODIFY_QUIZ_CONTENT])); } public function testWrongUserTypeReturnFalse(): void diff --git a/translations/messages+intl-icu.nl.xliff b/translations/messages+intl-icu.nl.xliff index 8e637e9..ee5c174 100644 --- a/translations/messages+intl-icu.nl.xliff +++ b/translations/messages+intl-icu.nl.xliff @@ -41,6 +41,22 @@ Add a quiz to {name} Voeg een test toe aan {name} + + Add answer + Antwoord toevoegen + + + Add label + Label toevoegen + + + Add question + Vraag toevoegen + + + All + Alle + All Seasons Alle seizoenen @@ -57,14 +73,26 @@ Are you sure you want to clear all the results? This will also delete all the eliminations. Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg. + + Are you sure you want to delete this question from the question bank? + Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen? + Are you sure you want to delete this quiz? Weet je zeker dat je deze test wilt verwijderen? + + Assign + Toewijzen + Back Terug + + Backoffice + Backoffice + Candidate Kandidaat @@ -133,6 +161,14 @@ Deactivate Quiz Deactiveer test + + Deactivate the quiz before undoing the finalization + Deactiveer de test voordat je de finalisatie ongedaan maakt + + + Delete + Verwijderen + Delete Quiz... Test verwijderen... @@ -141,10 +177,22 @@ Download Template Download sjabloon + + Draft + Concept + EMPTY LEEG + + Edit + Bewerken + + + Edit question + Vraag bewerken + Email E-mail @@ -161,6 +209,18 @@ Error clearing quiz Fout bij het leegmaken van de test + + Export to XLSX + Exporteren naar XLSX + + + Finalize + Finaliseren + + + Finalized + Gefinaliseerd + Green Groen @@ -193,14 +253,34 @@ Inactive Inactief + + Invalid label name + Ongeldige labelnaam + Invalid season code Ongeldige seizoencode + + Label added + Label toegevoegd + + + Label removed + Label verwijderd + + + Labels + Labels + Load Prepared Elimination Laad voorbereide eliminatie + + Locked (answers given) + Vergrendeld (antwoorden gegeven) + Logout Uitloggen @@ -221,6 +301,10 @@ Name Naam + + New label + Nieuw label + Next Volgende @@ -233,6 +317,14 @@ No active quiz Geen actieve test + + No candidates + Geen kandidaten + + + No questions in the question bank yet + Nog geen vragen in de vragenbank + No quizzes Geen tests @@ -249,6 +341,10 @@ Number of dropouts: Aantal afvallers: + + Open + Openen + Overview Overzicht @@ -297,6 +393,30 @@ Previous Vorige + + Question + Vraag + + + Question added to quiz %quiz% + Vraag toegevoegd aan test %quiz% + + + Question added to the question bank + Vraag toegevoegd aan de vragenbank + + + Question bank + Vragenbank + + + Question removed from the question bank + Vraag verwijderd uit de vragenbank + + + Question updated + Vraag bijgewerkt + Questions Vragen @@ -325,6 +445,10 @@ Quiz cleared Test leeggemaakt + + Quiz cleared and no longer finalized + Test leeggemaakt en niet langer gefinaliseerd + Quiz completed Test voltooid @@ -333,6 +457,14 @@ Quiz deleted Test verwijderd + + Quiz finalized + Test gefinaliseerd + + + Quiz is no longer finalized + Test is niet langer gefinaliseerd + Quiz name Testnaam @@ -353,6 +485,10 @@ Remember me Onthoud mij + + Remove label + Label verwijderen + Repeat Password Herhaal wachtwoord @@ -361,6 +497,10 @@ Results & Elimination + + Reusable + Herbruikbaar + Save Opslaan @@ -413,6 +553,18 @@ The password fields must match. De wachtwoorden moeten overeen komen. + + The quiz cannot be finalized while it has errors + De test kan niet gefinaliseerd worden zolang er fouten zijn + + + The quiz has already been filled in and can no longer be altered + De test is al ingevuld en kan niet meer aangepast worden + + + The quiz must be finalized before it can be activated + De test moet gefinaliseerd zijn voordat deze geactiveerd kan worden + There are no answers for this question Er zijn geen antwoorden voor deze vraag @@ -421,10 +573,30 @@ There is no active quiz Er is geen test actief + + This question has already been used + Deze vraag is al gebruikt + + + This question has been used in a quiz. The copy in the quiz will not be affected. + Deze vraag is gebruikt in een test. De kopie in de test blijft ongewijzigd. + + + This quiz can no longer be altered + Deze test kan niet meer aangepast worden + Time Tijd + + Undo finalization + Finalisatie ongedaan maken + + + Used in + Gebruikt in + Yes Ja diff --git a/translations/validators.nl.xliff b/translations/validators.nl.xliff index a0a28de..567871e 100644 --- a/translations/validators.nl.xliff +++ b/translations/validators.nl.xliff @@ -9,6 +9,14 @@ A PHP extension caused the upload to fail. De upload is mislukt vanwege een PHP-extensie. + + A question must have exactly one correct answer + Een vraag moet precies één goed antwoord hebben + + + A question needs at least two answers + Een vraag heeft minstens twee antwoorden nodig + An empty file is not allowed. Lege bestanden zijn niet toegestaan. @@ -65,6 +73,10 @@ Please enter a valid URL. Vul een geldige URL in. + + Please enter a valid UUID. + Vul een geldige UUID in. + Please enter a valid birthdate. Vul een geldige geboortedatum in. @@ -361,6 +373,10 @@ This URL is missing a top-level domain. Deze URL mist een top-level domein. + + This XML payload is too large ({{ size }} bytes): it exceeds the limit of {{ limit }} bytes. + Deze XML-payload is te groot ({{ size }} bytes): deze overschrijdt de limiet van {{ limit }} bytes. + This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. Deze collectie moet exact één element bevatten.|Deze collectie moet exact {{ limit }} elementen bevatten. @@ -425,6 +441,10 @@ This value contains characters that are not allowed by the current restriction-level. Deze waarde bevat tekens die niet zijn toegestaan volgens het huidige beperkingsniveau. + + This value does not conform to the expected XSD schema. + Deze waarde voldoet niet aan het verwachte XSD-schema. + This value does not match the expected {{ charset }} charset. Deze waarde is niet in de verwachte tekencodering {{ charset }}. @@ -485,6 +505,10 @@ This value is not a valid country. Deze waarde is geen geldig land. + + This value is not a valid cron expression. + Deze waarde is geen geldige cron-expressie. + This value is not a valid currency. Deze waarde is geen geldige valuta. @@ -525,6 +549,10 @@ This value is not a valid week. Deze waarde is geen geldige week. + + This value is not valid XML. + Deze waarde is geen geldige XML. + This value is not valid. Deze waarde is niet geldig.