diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c112bf..d960e1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,8 @@ jobs: *.cache-from=type=gha,scope=${{github.ref}}-devbuild - name: Start services run: docker compose up php database --wait --no-build + - name: Build SCSS + run: docker compose exec -T php bin/console sass:build - name: Create test database run: docker compose exec -T php bin/console -e test doctrine:database:create - name: Run migrations diff --git a/.idea/TijdVoorDeTest.iml b/.idea/TijdVoorDeTest.iml index 486f93d..bcec616 100644 --- a/.idea/TijdVoorDeTest.iml +++ b/.idea/TijdVoorDeTest.iml @@ -170,6 +170,7 @@ + diff --git a/.idea/php.xml b/.idea/php.xml index 0946da5..9c78f81 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -41,169 +41,170 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CLAUDE.md b/CLAUDE.md index c4e0c8d..4827ccf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,6 +224,15 @@ Auto-executed scripts on install/update: - `assets:install` - Copy public assets - `importmap:install` - JS import map setup +## Writing Style (Help Content & UI Text) + +When writing Dutch help content in `templates/backoffice/help/nl/`: + +- **No em-dashes** (—): use a comma or restructure the sentence instead. +- **No semicolons** (;): use a comma. Semicolons are technically correct but read as AI-generated text. +- **Natural Dutch**: write the way a person would explain it to a colleague, not in formal documentation style. +- **Colons after bold labels** (e.g. `Label: description`) are fine and intentional. + ## Notes for Future Work - The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php` diff --git a/assets/backoffice.js b/assets/backoffice.js index 3690b45..7758b59 100644 --- a/assets/backoffice.js +++ b/assets/backoffice.js @@ -1,4 +1,5 @@ import 'bootstrap/dist/css/bootstrap.min.css'; +import 'bootstrap-icons/font/bootstrap-icons.min.css'; import './styles/backoffice.scss'; import './stimulus.js'; import './bootstrap.js'; diff --git a/assets/controllers/bo/form_collection_controller.js b/assets/controllers/bo/form_collection_controller.js new file mode 100644 index 0000000..e7a7d4c --- /dev/null +++ b/assets/controllers/bo/form_collection_controller.js @@ -0,0 +1,104 @@ +import {Controller} from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['collection']; + static values = {prototype: String}; + + connect() { + this.index = this.collectionTarget.children.length; + this._setupDrag(); + this._syncOrdering(); + } + + addItem() { + const item = document.createElement('div'); + item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index); + const el = item.firstElementChild; + this.collectionTarget.appendChild(el); + this._makeDraggable(el); + this.index++; + this._syncOrdering(); + } + + removeItem(event) { + event.target.closest('[data-collection-item]').remove(); + } + + sortAlphabetically() { + const items = [...this.collectionTarget.children]; + items.sort((a, b) => { + const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase(); + const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase(); + return textA.localeCompare(textB); + }); + items.forEach(item => this.collectionTarget.appendChild(item)); + this._syncOrdering(); + } + + randomize() { + const items = [...this.collectionTarget.children]; + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + items.forEach(item => this.collectionTarget.appendChild(item)); + this._syncOrdering(); + } + + // — drag-and-drop — + + _setupDrag() { + [...this.collectionTarget.children].forEach(el => this._makeDraggable(el)); + } + + _makeDraggable(el) { + const handle = el.querySelector('[data-drag-handle]'); + if (!handle) return; + + handle.setAttribute('draggable', 'true'); + + handle.addEventListener('dragstart', (e) => { + this._dragging = el; + el.classList.add('opacity-50'); + e.dataTransfer.effectAllowed = 'move'; + }); + + handle.addEventListener('dragend', () => { + this._dragging = null; + el.classList.remove('opacity-50'); + this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary')); + }); + + el.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!this._dragging || this._dragging === el) return; + e.dataTransfer.dropEffect = 'move'; + const rect = el.getBoundingClientRect(); + const isBottom = e.clientY > rect.top + rect.height / 2; + el.classList.toggle('border-top', !isBottom); + el.classList.toggle('border-bottom', isBottom); + el.classList.add('border-primary'); + }); + + el.addEventListener('dragleave', () => { + el.classList.remove('border-top', 'border-bottom', 'border-primary'); + }); + + el.addEventListener('drop', (e) => { + e.preventDefault(); + el.classList.remove('border-top', 'border-bottom', 'border-primary'); + if (!this._dragging || this._dragging === el) return; + const rect = el.getBoundingClientRect(); + const isBottom = e.clientY > rect.top + rect.height / 2; + this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el); + this._syncOrdering(); + }); + } + + _syncOrdering() { + [...this.collectionTarget.children].forEach((el, i) => { + const input = el.querySelector('input[name*="[ordering]"]'); + if (input) input.value = i; + }); + } +} diff --git a/composer.json b/composer.json index b060bfb..2c7e4cd 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "symfony/form": "8.1.*", "symfony/framework-bundle": "8.1.*", "symfony/mailer": "8.1.*", + "symfony/object-mapper": "8.1.*", "symfony/property-access": "8.1.*", "symfony/property-info": "8.1.*", "symfony/runtime": "8.1.*", diff --git a/composer.lock b/composer.lock index f647b15..7e30b27 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f8e4107825dba89eaa38d067d47856ce", + "content-hash": "7171824ca13f4df0801dfa5d7f58d6a0", "packages": [ { "name": "composer/pcre", @@ -5337,6 +5337,79 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/object-mapper", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/object-mapper.git", + "reference": "f2d118d3ced275117b83acc5b57f6611ab38cd14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/object-mapper/zipball/f2d118d3ced275117b83acc5b57f6611ab38cd14", + "reference": "f2d118d3ced275117b83acc5b57f6611ab38cd14", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^2.0" + }, + "conflict": { + "symfony/property-access": "<7.2" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ObjectMapper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to map an object to another object", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/object-mapper/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-17T10:12:54+00:00" + }, { "name": "symfony/options-resolver", "version": "v8.1.0", diff --git a/config/packages/stof_doctrine_extensions.yaml b/config/packages/stof_doctrine_extensions.yaml index a973161..872114f 100644 --- a/config/packages/stof_doctrine_extensions.yaml +++ b/config/packages/stof_doctrine_extensions.yaml @@ -6,3 +6,4 @@ stof_doctrine_extensions: default: timestampable: true softdeleteable: true + loggable: true diff --git a/importmap.php b/importmap.php index 0398a30..10bb9c9 100644 --- a/importmap.php +++ b/importmap.php @@ -12,33 +12,26 @@ declare(strict_types=1); * be used as an "entrypoint" (and passed to the importmap() Twig function). * * The "importmap:require" command can be used to add new entries to this file. + * + * @return array */ return [ - 'quiz' => [ - 'path' => './assets/quiz.js', - 'entrypoint' => true, - ], - 'backoffice' => [ - 'path' => './assets/backoffice.js', - 'entrypoint' => true, - ], - '@symfony/stimulus-bundle' => [ - 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', - ], - 'bootstrap' => [ - 'version' => '5.3.8', - ], - '@popperjs/core' => [ - 'version' => '2.11.8', - ], - 'bootstrap/dist/css/bootstrap.min.css' => [ - 'version' => '5.3.8', - 'type' => 'css', - ], - '@hotwired/stimulus' => [ - 'version' => '3.2.2', - ], - '@hotwired/turbo' => [ - 'version' => '8.0.23', - ], + 'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true], + 'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true], + '@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'], + 'bootstrap' => ['version' => '5.3.8'], + '@popperjs/core' => ['version' => '2.11.8'], + 'bootstrap/dist/css/bootstrap.min.css' => ['version' => '5.3.8', 'type' => 'css'], + '@hotwired/stimulus' => ['version' => '3.2.2'], + '@hotwired/turbo' => ['version' => '8.0.23'], + 'bootstrap-icons/font/bootstrap-icons.min.css' => ['version' => '1.13.1', 'type' => 'css'], ]; diff --git a/migrations/Version20260705142647.php b/migrations/Version20260705142647.php new file mode 100644 index 0000000..ec9a9aa --- /dev/null +++ b/migrations/Version20260705142647.php @@ -0,0 +1,74 @@ +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, question_id UUID DEFAULT NULL, bank_question_id UUID NOT NULL, quiz_id UUID NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_775833AD1E27F6BF ON bank_question_usage (question_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 ext_log_entries (data JSON DEFAULT NULL, id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(8) NOT NULL, logged_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, object_id VARCHAR(64) DEFAULT NULL, object_class VARCHAR(191) NOT NULL, version INT NOT NULL, username VARCHAR(191) DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX log_class_lookup_idx ON ext_log_entries (object_class)'); + $this->addSql('CREATE INDEX log_date_lookup_idx ON ext_log_entries (logged_at)'); + $this->addSql('CREATE INDEX log_user_lookup_idx ON ext_log_entries (username)'); + $this->addSql('CREATE INDEX log_version_lookup_idx ON ext_log_entries (object_id, object_class, version)'); + $this->addSql("CREATE TABLE question_label (id UUID NOT NULL, colour VARCHAR(16) DEFAULT 'secondary' NOT NULL, slug VARCHAR(64) 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('CREATE UNIQUE INDEX uq_question_label_slug_season ON question_label (slug, 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_775833AD1E27F6BF FOREIGN KEY (question_id) REFERENCES question (id) ON DELETE SET NULL NOT DEFERRABLE'); + $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'); + } + + #[\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_775833AD1E27F6BF'); + $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 ext_log_entries'); + $this->addSql('DROP TABLE question_label'); + $this->addSql('ALTER TABLE quiz DROP finalized_at'); + } +} diff --git a/src/Controller/Backoffice/PrepareEliminationController.php b/src/Controller/Backoffice/PrepareEliminationController.php index 4627372..bf7953e 100644 --- a/src/Controller/Backoffice/PrepareEliminationController.php +++ b/src/Controller/Backoffice/PrepareEliminationController.php @@ -15,6 +15,7 @@ use Tvdt\Controller\AbstractController; use Tvdt\Entity\Elimination; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; +use Tvdt\Enum\FlashType; use Tvdt\Factory\EliminationFactory; final class PrepareEliminationController extends AbstractController @@ -52,7 +53,7 @@ final class PrepareEliminationController extends AbstractController return $this->redirectToRoute('tvdt_elimination', ['elimination' => $elimination->id]); } - $this->addFlash('success', 'Elimination updated'); + $this->addFlash(FlashType::Success, 'Elimination updated'); return $this->redirectToRoute('tvdt_prepare_elimination_view', ['elimination' => $elimination->id]); } diff --git a/src/Controller/Backoffice/QuestionBankController.php b/src/Controller/Backoffice/QuestionBankController.php new file mode 100644 index 0000000..c518167 --- /dev/null +++ b/src/Controller/Backoffice/QuestionBankController.php @@ -0,0 +1,387 @@ + 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)], + ), + ); + } + } +} diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index c12ffae..275e1d5 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; @@ -154,7 +156,13 @@ class QuizController extends AbstractController public function answerMapping(Season $season, Quiz $quiz): Response { $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); - \assert($fetchedQuiz->questions->count() > 0); + + if ($fetchedQuiz->questions->isEmpty()) { + $this->addFlash(FlashType::Warning, $this->translator->trans('This quiz has no questions yet')); + + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); + } + $firstQuestion = $fetchedQuiz->questions->first(); \assert($firstQuestion instanceof Question); @@ -233,7 +241,7 @@ class QuizController extends AbstractController $this->em->flush(); - $this->addFlash('success', $this->translator->trans('Candidate answers saved')); + $this->addFlash(FlashType::Success, $this->translator->trans('Candidate answers saved')); return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [ 'seasonCode' => $season->seasonCode, @@ -250,13 +258,28 @@ class QuizController extends AbstractController requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'], methods: ['POST'], )] - public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse + public function enableQuiz(Season $season, ?Quiz $quiz, Request $request): 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_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); + } + $season->activeQuiz = $quiz; $this->em->flush(); if ($quiz instanceof Quiz) { - return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); + } + + // When deactivating, stay on the quiz page if one was passed + $previousQuizId = $request->request->getString('redirect_quiz'); + if ('' !== $previousQuizId) { + $previousQuiz = $this->em->getRepository(Quiz::class)->find($previousQuizId); + if ($previousQuiz instanceof Quiz && $previousQuiz->season === $season) { + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $previousQuiz->id]); + } } return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]); @@ -274,9 +297,55 @@ class QuizController extends AbstractController { try { $this->quizRepository->clearQuiz($quiz); - $this->addFlash('success', $this->translator->trans('Quiz cleared')); + $this->addFlash(FlashType::Success, $this->translator->trans('Quiz cleared and no longer finalized')); } catch (ErrorClearingQuizException) { - $this->addFlash('error', $this->translator->trans('Error clearing quiz')); + $this->addFlash(FlashType::Danger, $this->translator->trans('Error clearing quiz')); + } + + 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(FlashType::Success, $this->translator->trans('Quiz finalized')); + } else { + $this->addFlash(FlashType::Warning, $this->translator->trans('The quiz is already 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(FlashType::Success, $this->translator->trans('Quiz is no longer finalized')); } return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); @@ -294,7 +363,7 @@ class QuizController extends AbstractController { $this->quizRepository->deleteQuiz($quiz); - $this->addFlash('success', $this->translator->trans('Quiz deleted')); + $this->addFlash(FlashType::Success, $this->translator->trans('Quiz deleted')); return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]); } @@ -359,7 +428,7 @@ class QuizController extends AbstractController $this->em->flush(); - $this->addFlash('success', $this->translator->trans('Candidate status updated')); + $this->addFlash(FlashType::Success, $this->translator->trans('Candidate status updated')); return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); } diff --git a/src/Controller/Backoffice/QuizQuestionController.php b/src/Controller/Backoffice/QuizQuestionController.php new file mode 100644 index 0000000..140e174 --- /dev/null +++ b/src/Controller/Backoffice/QuizQuestionController.php @@ -0,0 +1,75 @@ + self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID, 'question' => Requirement::UUID], + )] + public function edit(Season $season, Quiz $quiz, Question $question, Request $request): Response + { + if ($question->quiz !== $quiz || $quiz->season !== $season) { + throw new NotFoundHttpException(); + } + + $form = $this->createForm(QuestionFormType::class, $question); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->applyAnswerOrdering($question); + $this->em->flush(); + + $this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); + + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [ + 'seasonCode' => $season->seasonCode, + 'quiz' => $quiz->id, + ]); + } + + return $this->render('backoffice/quiz/question_form.html.twig', [ + 'season' => $season, + 'quiz' => $quiz, + 'question' => $question, + 'form' => $form, + ]); + } + + private function applyAnswerOrdering(Question $question): void + { + $ordering = 1; + foreach ($question->answers as $answer) { + $answer->ordering = $ordering++; + } + } +} diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php index 198ca9a..3082050 100644 --- a/src/Controller/Backoffice/SeasonController.php +++ b/src/Controller/Backoffice/SeasonController.php @@ -4,13 +4,19 @@ declare(strict_types=1); namespace Tvdt\Controller\Backoffice; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormError; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Contracts\Translation\TranslatorInterface; use Tvdt\Controller\AbstractController; use Tvdt\Entity\Candidate; @@ -39,7 +45,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 +85,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', ]); } @@ -78,7 +120,7 @@ class SeasonController extends AbstractController return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]); } - return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]); + return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]); } #[IsGranted(SeasonVoter::EDIT, subject: 'season')] @@ -112,4 +154,52 @@ class SeasonController extends AbstractController return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]); } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/add-blank-quiz', + name: 'tvdt_backoffice_quiz_add_blank', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + priority: 10, + )] + public function addBlankQuiz(Request $request, Season $season): Response + { + $form = $this->createFormBuilder(new Quiz()) + ->add('name', TextType::class, [ + 'label' => $this->translator->trans('Quiz name'), + 'translation_domain' => false, + 'constraints' => [ + new NotBlank(), + new Length(max: 64), + ], + ]) + ->add('save', SubmitType::class, ['label' => 'Create']) + ->getForm(); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var Quiz $quiz */ + $quiz = $form->getData(); + $quiz->season = $season; + $this->em->persist($quiz); + + try { + $this->em->flush(); + } catch (UniqueConstraintViolationException) { + $form->get('name')->addError(new FormError($this->translator->trans('A quiz with this name already exists in this season'))); + + return $this->render('/backoffice/quiz_add_blank.html.twig', ['form' => $form, 'season' => $season]); + } + + $this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!')); + + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [ + 'seasonCode' => $season->seasonCode, + 'quiz' => $quiz->id, + ]); + } + + return $this->render('/backoffice/quiz_add_blank.html.twig', ['form' => $form, 'season' => $season]); + } } diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 250ffe5..7b0c415 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Translation\TranslatorInterface; use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface; use Tvdt\Entity\User; +use Tvdt\Enum\FlashType; use Tvdt\Form\RegistrationFormType; use Tvdt\Repository\UserRepository; use Tvdt\Security\EmailVerifier; @@ -95,7 +96,7 @@ final class RegistrationController extends AbstractController return $this->redirectToRoute('tvdt_register'); } - $this->addFlash('success', $this->translator->trans('Your email address has been verified.')); + $this->addFlash(FlashType::Success->value, $this->translator->trans('Your email address has been verified.')); return $this->redirectToRoute('tvdt_backoffice_index'); } 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..28733d9 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,68 @@ 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'); + $location->slug = 'locatie'; + + $season->addQuestionLabel($location); + $finale = new QuestionLabel('Finale'); + $finale->slug = '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/AbstractBaseAnswer.php b/src/Entity/AbstractBaseAnswer.php new file mode 100644 index 0000000..fa5ff79 --- /dev/null +++ b/src/Entity/AbstractBaseAnswer.php @@ -0,0 +1,27 @@ + 0])] + public int $ordering = 0; + + 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/Answer.php b/src/Entity/Answer.php index f862b36..ddf1fb6 100644 --- a/src/Entity/Answer.php +++ b/src/Entity/Answer.php @@ -6,14 +6,13 @@ 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; use Tvdt\Repository\AnswerRepository; #[ORM\Entity(repositoryClass: AnswerRepository::class)] -class Answer implements \Stringable +class Answer extends AbstractBaseAnswer { #[ORM\Column(type: UuidType::NAME)] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] @@ -21,9 +20,6 @@ class Answer implements \Stringable #[ORM\Id] public private(set) Uuid $id; - #[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])] - public int $ordering = 0; - #[ORM\JoinColumn(nullable: false)] #[ORM\ManyToOne(inversedBy: 'answers')] public Question $question; @@ -36,12 +32,9 @@ class Answer implements \Stringable #[ORM\OneToMany(targetEntity: GivenAnswer::class, mappedBy: 'answer', orphanRemoval: true)] public private(set) Collection $givenAnswers; - public function __construct( - #[ORM\Column(length: 255)] - public string $text, - #[ORM\Column] - public bool $isRightAnswer = false, - ) { + public function __construct(string $text, bool $isRightAnswer = false) + { + parent::__construct($text, $isRightAnswer); $this->candidates = new ArrayCollection(); $this->givenAnswers = new ArrayCollection(); } @@ -57,9 +50,4 @@ class Answer implements \Stringable { $this->candidates->removeElement($candidate); } - - public function __toString(): string - { - return $this->text; - } } diff --git a/src/Entity/BankAnswer.php b/src/Entity/BankAnswer.php new file mode 100644 index 0000000..0f29d62 --- /dev/null +++ b/src/Entity/BankAnswer.php @@ -0,0 +1,26 @@ + false])] + public bool $reusable = false; + + /** @var Collection */ + #[Map(if: false)] + #[ORM\ManyToMany(targetEntity: QuestionLabel::class, inversedBy: 'bankQuestions')] + public private(set) Collection $labels; + + /** @var Collection */ + #[Map(if: false)] + #[ORM\OneToMany(targetEntity: BankAnswer::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)] + #[ORM\OrderBy(['ordering' => 'ASC'])] + public private(set) Collection $answers; + + /** @var Collection */ + #[Map(if: false)] + #[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 bool $isUsed { + get => !$this->usages->isEmpty(); + } + + public bool $canBeAssigned { + get => $this->reusable || !$this->isUsed; + } + + /** True when the question is fully complete and can be assigned to a quiz. */ + public bool $isCompleteForQuiz { + get => $this->answers->count() >= 2 + && 1 === $this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count(); + } + + 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 + { + if ($this->answers->isEmpty()) { + return; + } + + $this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count(); + + if ($this->answers->count() < 2) { + $context->buildViolation('A question needs at least two answers') + ->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..bcca043 --- /dev/null +++ b/src/Entity/BankQuestionUsage.php @@ -0,0 +1,40 @@ + + */ +#[ORM\Entity(repositoryClass: LogEntryRepository::class)] +#[ORM\Index(name: 'log_class_lookup_idx', columns: ['object_class'])] +#[ORM\Index(name: 'log_date_lookup_idx', columns: ['logged_at'])] +#[ORM\Index(name: 'log_user_lookup_idx', columns: ['username'])] +#[ORM\Index(name: 'log_version_lookup_idx', columns: ['object_id', 'object_class', 'version'])] +#[ORM\Table(name: 'ext_log_entries')] +class LogEntry extends AbstractLogEntry +{ + #[ORM\Column(type: Types::JSON, nullable: true)] + #[\Override] + protected $data; +} diff --git a/src/Entity/QuestionLabel.php b/src/Entity/QuestionLabel.php new file mode 100644 index 0000000..0abfde0 --- /dev/null +++ b/src/Entity/QuestionLabel.php @@ -0,0 +1,51 @@ + */ + #[ORM\ManyToMany(targetEntity: BankQuestion::class, mappedBy: 'labels')] + public private(set) Collection $bankQuestions; + + #[ORM\Column(length: 16, enumType: LabelColour::class, options: ['default' => 'secondary'])] + public LabelColour $colour = LabelColour::Gray; + + #[ORM\Column(length: 64)] + public string $slug = ''; + + 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..77cd064 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,19 @@ class Quiz return $this; } + public bool $isFinalized { + get => $this->finalizedAt instanceof \DateTimeImmutable; + } + + public bool $hasStartedCandidates { + get => $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 bool $isLocked { + get => $this->isFinalized || $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/Enum/LabelColour.php b/src/Enum/LabelColour.php new file mode 100644 index 0000000..529d026 --- /dev/null +++ b/src/Enum/LabelColour.php @@ -0,0 +1,31 @@ + new TranslatableMessage('Blue'), + self::Gray => new TranslatableMessage('Gray'), + self::Green => new TranslatableMessage('Green'), + self::Red => new TranslatableMessage('Red'), + self::Yellow => new TranslatableMessage('Yellow'), + self::Cyan => new TranslatableMessage('Cyan'), + self::White => new TranslatableMessage('White'), + }; + } +} 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 @@ + + */ +abstract class AbstractBaseAnswerFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('ordering', HiddenType::class, ['empty_data' => '0']) + ->add('text', TextType::class, [ + 'label' => false, + 'attr' => ['placeholder' => 'Answer', 'maxlength' => 255], + ]) + ->add('isRightAnswer', CheckboxType::class, [ + 'label' => 'Correct', + 'required' => false, + ]) + ; + } +} diff --git a/src/Form/AnswerFormType.php b/src/Form/AnswerFormType.php new file mode 100644 index 0000000..38f01bf --- /dev/null +++ b/src/Form/AnswerFormType.php @@ -0,0 +1,20 @@ + */ +class AnswerFormType extends AbstractBaseAnswerFormType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Answer::class, + 'empty_data' => static fn (): Answer => new Answer(''), + ]); + } +} diff --git a/src/Form/BankAnswerFormType.php b/src/Form/BankAnswerFormType.php new file mode 100644 index 0000000..0be1eff --- /dev/null +++ b/src/Form/BankAnswerFormType.php @@ -0,0 +1,20 @@ + */ +class BankAnswerFormType extends AbstractBaseAnswerFormType +{ + 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/Form/QuestionFormType.php b/src/Form/QuestionFormType.php new file mode 100644 index 0000000..9cfc317 --- /dev/null +++ b/src/Form/QuestionFormType.php @@ -0,0 +1,46 @@ + */ +class QuestionFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('question', TextType::class, [ + 'label' => 'Question', + 'attr' => ['maxlength' => 255], + ]) + ->add('answers', CollectionType::class, [ + 'label' => 'Answers', + 'entry_type' => AnswerFormType::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' => Question::class, + ]); + } +} diff --git a/src/Repository/BankQuestionRepository.php b/src/Repository/BankQuestionRepository.php new file mode 100644 index 0000000..52134a0 --- /dev/null +++ b/src/Repository/BankQuestionRepository.php @@ -0,0 +1,71 @@ + */ +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') + ->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 $questions */ + $questions = $queryBuilder->getQuery()->getResult(); + + if ([] === $questions) { + return []; + } + + // Load each many-to-many/one-to-many collection in a separate query to avoid + // the Cartesian-product row explosion that occurs when joining multiple collections at once. + $this->createQueryBuilder('bq') + ->select('partial bq.{id}', 'ba') + ->leftJoin('bq.answers', 'ba') + ->where('bq.season = :season') + ->setParameter('season', $season) + ->getQuery() + ->getResult(); + + $this->createQueryBuilder('bq') + ->select('partial bq.{id}', 'l') + ->leftJoin('bq.labels', 'l') + ->where('bq.season = :season') + ->setParameter('season', $season) + ->getQuery() + ->getResult(); + + $this->createQueryBuilder('bq') + ->select('partial bq.{id}', 'u', 'uq') + ->leftJoin('bq.usages', 'u') + ->leftJoin('u.quiz', 'uq') + ->where('bq.season = :season') + ->setParameter('season', $season) + ->getQuery() + ->getResult(); + + return $questions; + } +} diff --git a/src/Repository/QuestionLabelRepository.php b/src/Repository/QuestionLabelRepository.php new file mode 100644 index 0000000..b18e885 --- /dev/null +++ b/src/Repository/QuestionLabelRepository.php @@ -0,0 +1,39 @@ + */ +class QuestionLabelRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, QuestionLabel::class); + } + + public function findBySlugAndSeason(string $slug, Season $season): ?QuestionLabel + { + return $this->findOneBy(['slug' => $slug, 'season' => $season]); + } + + public function slugExistsForSeason(string $slug, Season $season, ?QuestionLabel $excluding = null): bool + { + $qb = $this->createQueryBuilder('l') + ->where('l.slug = :slug') + ->andWhere('l.season = :season') + ->setParameter('slug', $slug) + ->setParameter('season', $season); + + if ($excluding instanceof QuestionLabel) { + $qb->andWhere('l.id != :id')->setParameter('id', $excluding->id); + } + + return (int) $qb->select('COUNT(l.id)')->getQuery()->getSingleScalarResult() > 0; + } +} diff --git a/src/Repository/QuizRepository.php b/src/Repository/QuizRepository.php index e4a2b5d..b573ae6 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 { @@ -48,6 +72,20 @@ class QuizRepository extends ServiceEntityRepository DQL) ->setParameter('quiz', $quiz) ->execute(); + + $em->createQuery(<<setParameter('quiz', $quiz) + ->execute(); + + $em->createQuery(<<setParameter('quiz', $quiz) + ->execute(); } // @codeCoverageIgnoreStart catch (\Throwable $throwable) { @@ -113,8 +151,8 @@ class QuizRepository extends ServiceEntityRepository { return $this->getEntityManager()->createQuery(<<setParameter('id', $id)->getSingleResult(); } @@ -127,8 +165,8 @@ class QuizRepository extends ServiceEntityRepository { return $this->getEntityManager()->createQuery(<< */ +/** @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..f1fb5a9 --- /dev/null +++ b/src/Service/QuestionBankService.php @@ -0,0 +1,121 @@ +season !== $quiz->season) { + throw new \InvalidArgumentException('Bank question and quiz belong to different seasons'); + } + + if ($quiz->isLocked) { + throw new QuizLockedException(); + } + + if (!$bankQuestion->isCompleteForQuiz) { + throw new BankQuestionIncompleteException(); + } + + $this->entityManager->wrapInTransaction(function () use ($bankQuestion, $quiz): void { + // Pessimistic write lock serialises concurrent assignment attempts for the same BankQuestion + $this->entityManager->lock($bankQuestion, LockMode::PESSIMISTIC_WRITE); + + if (!$bankQuestion->canBeAssigned || $bankQuestion->isUsedInQuiz($quiz)) { + throw new BankQuestionAlreadyUsedException(); + } + + $maxOrdering = 0; + foreach ($quiz->questions as $existingQuestion) { + $maxOrdering = max($maxOrdering, $existingQuestion->ordering); + } + + /** @var Question $question */ + $question = $this->objectMapper->map($bankQuestion, Question::class); + $question->ordering = $maxOrdering + 1; + + foreach ($bankQuestion->answers as $bankAnswer) { + /** @var Answer $answer */ + $answer = $this->objectMapper->map($bankAnswer, Answer::class); + $question->addAnswer($answer); + } + + $quiz->addQuestion($question); + + $usage = new BankQuestionUsage($bankQuestion, $quiz); + $usage->question = $question; + + $bankQuestion->addUsage($usage); + + $this->entityManager->persist($question); + $this->entityManager->flush(); + }); + } + + /** + * Propagate bank question edits to a quiz copy. + * Only safe on quizzes where no candidate has started (no GivenAnswers exist yet). + */ + public function syncToQuiz(BankQuestion $bankQuestion, BankQuestionUsage $usage): void + { + $question = $usage->question; + if (!$question instanceof Question) { + return; + } + + $question->question = $bankQuestion->question; + + // Replace answers (safe: no started candidates means no GivenAnswers) + foreach ($question->answers->toArray() as $existingAnswer) { + $question->answers->removeElement($existingAnswer); + $this->entityManager->remove($existingAnswer); + } + + foreach ($bankQuestion->answers as $bankAnswer) { + /** @var Answer $answer */ + $answer = $this->objectMapper->map($bankAnswer, Answer::class); + $question->addAnswer($answer); + } + } + + /** Remove the quiz copy created by this usage and delete the usage record. */ + public function unassignFromQuiz(BankQuestionUsage $usage): void + { + $question = $usage->question; + if ($question instanceof Question) { + $question->quiz->questions->removeElement($question); + $this->entityManager->remove($question); + } + + $usage->bankQuestion->usages->removeElement($usage); + $this->entityManager->remove($usage); + $this->entityManager->flush(); + } +} diff --git a/templates/backoffice/help/index.html.twig b/templates/backoffice/help/index.html.twig new file mode 100644 index 0000000..6eea998 --- /dev/null +++ b/templates/backoffice/help/index.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/index.html.twig', + 'backoffice/help/nl/index.html.twig', +]) }} diff --git a/templates/backoffice/help/nl/index.html.twig b/templates/backoffice/help/nl/index.html.twig new file mode 100644 index 0000000..1f8595b --- /dev/null +++ b/templates/backoffice/help/nl/index.html.twig @@ -0,0 +1,10 @@ +
Aan de slag
+

Elk seizoen groepeert één spel met alle bijbehorende testen en kandidaten. De seizoenscode is de link die kandidaten gebruiken om een test te starten, en is alleen actief als er een actieve test is.

+
Globale werkwijze
+
    +
  1. Seizoen aanmaken en kandidaten toevoegen
  2. +
  3. Test aanmaken via Excel of de vragenbank
  4. +
  5. Test afronden en activeren
  6. +
  7. Kandidaten laten deelnemen (eigen apparaat of gedeelde laptop)
  8. +
  9. Resultaten bekijken en eliminatie starten
  10. +
diff --git a/templates/backoffice/help/nl/prepare_elimination.html.twig b/templates/backoffice/help/nl/prepare_elimination.html.twig new file mode 100644 index 0000000..4187c1d --- /dev/null +++ b/templates/backoffice/help/nl/prepare_elimination.html.twig @@ -0,0 +1,3 @@ +
Eliminatie voorbereiden
+

Kies voor elke kandidaat een kleur: groen betekent veilig, rood betekent geëlimineerd.

+

Gebruik Opslaan en starten om de eliminatie direct af te spelen, of sla eerst op en start later via het tabblad Resultaten.

diff --git a/templates/backoffice/help/nl/quiz_add.html.twig b/templates/backoffice/help/nl/quiz_add.html.twig new file mode 100644 index 0000000..fc40f8a --- /dev/null +++ b/templates/backoffice/help/nl/quiz_add.html.twig @@ -0,0 +1,8 @@ +
Test importeren via Excel
+

Upload een Excel-bestand met de vragen voor deze test. Na het uploaden kun je de vragen bekijken, controleren en de test afronden.

+
Verwacht formaat
+
    +
  • Eerste kolom: de vraagtekst
  • +
  • Volgende kolommen: de antwoordopties
  • +
  • Markeer het juiste antwoord met WAAR (Nederlandstalige Excel) of TRUE (Engelstalige Excel), alle andere antwoorden zet je op ONWAAR/FALSE
  • +
diff --git a/templates/backoffice/help/nl/quiz_add_blank.html.twig b/templates/backoffice/help/nl/quiz_add_blank.html.twig new file mode 100644 index 0000000..2983f9b --- /dev/null +++ b/templates/backoffice/help/nl/quiz_add_blank.html.twig @@ -0,0 +1,3 @@ +
Lege test aanmaken
+

Maak een lege test aan en voeg vragen toe vanuit de vragenbank. Handig als je vragen hergebruikt of ze van tevoren in de bank hebt klaargezet.

+

Na het aanmaken open je de test, voeg je vragen toe via het tabblad Overzicht en ronde je de test af voordat je hem activeert.

diff --git a/templates/backoffice/help/nl/quiz_answer_mapping.html.twig b/templates/backoffice/help/nl/quiz_answer_mapping.html.twig new file mode 100644 index 0000000..d378ca5 --- /dev/null +++ b/templates/backoffice/help/nl/quiz_answer_mapping.html.twig @@ -0,0 +1,3 @@ +
Antwoorden invullen
+

Gebruik dit formulier om antwoorden aan kandidaten toe te wijzen. Op deze manier kunnen er statistieken gemaakt worden hoe verdacht kandidaten zijn.

+

Navigeer met de knoppen Vorige en Volgende tussen vragen. Vink per kandidaat het gegeven antwoord aan en sla op.

diff --git a/templates/backoffice/help/nl/quiz_candidates.html.twig b/templates/backoffice/help/nl/quiz_candidates.html.twig new file mode 100644 index 0000000..e400cf7 --- /dev/null +++ b/templates/backoffice/help/nl/quiz_candidates.html.twig @@ -0,0 +1,6 @@ +
Kandidaten laten deelnemen
+

Kandidaten kunnen de test invullen via hun eigen apparaat, een of meerdere gedeelde laptops, of een mix daarvan.

+

Eigen apparaat: Deel de seizoenscode. Elke kandidaat bezoekt de site op zijn of haar telefoon of laptop, voert de eigen naam in en start de test.

+

Gedeelde laptop(s): Open de naamsinvoerpagina van tevoren op een of meerdere laptops. Elke kandidaat typt zijn of haar naam en start. Na afloop kan de volgende kandidaat hetzelfde doen op dezelfde of een andere laptop.

+
Status
+

Deactiveer een kandidaat als deze de test niet hoeft te maken, bijvoorbeeld na eerder uitgeschakeld zijn. Deactivering is per test en heeft geen invloed op andere testen.

diff --git a/templates/backoffice/help/nl/quiz_overview.html.twig b/templates/backoffice/help/nl/quiz_overview.html.twig new file mode 100644 index 0000000..7954888 --- /dev/null +++ b/templates/backoffice/help/nl/quiz_overview.html.twig @@ -0,0 +1,5 @@ +
Overzicht & afronden
+

Vragen met een rode markering in de lijst hiernaast bevatten een fout. Herstel deze vóór het afronden.

+

Afronden vergrendelt de test voor bewerking en maakt hem klaar voor kandidaten. Daarna kun je hem activeren.

+

Activeren stelt de test beschikbaar aan kandidaten. Er kan maar één test tegelijk actief zijn, activeer de volgende pas als iedereen de huidige heeft afgerond.

+

Test wissen verwijdert alle gegeven antwoorden en heft het afronden op, zodat je de test opnieuw kunt bewerken en uitvoeren.

diff --git a/templates/backoffice/help/nl/quiz_question_bank_form.html.twig b/templates/backoffice/help/nl/quiz_question_bank_form.html.twig new file mode 100644 index 0000000..24bae86 --- /dev/null +++ b/templates/backoffice/help/nl/quiz_question_bank_form.html.twig @@ -0,0 +1,4 @@ +
Vraag toevoegen
+

Voer de vraag in en voeg minimaal twee antwoordopties toe. Markeer precies één antwoord als correct.

+

Gebruik labels om vragen te organiseren in de vragenbank, bijvoorbeeld per aflevering of type vraag.

+

Markeer een vraag als herbruikbaar als deze in meerdere testen mag voorkomen, anders kan een vraag maar aan één test worden gekoppeld.

diff --git a/templates/backoffice/help/nl/quiz_result.html.twig b/templates/backoffice/help/nl/quiz_result.html.twig new file mode 100644 index 0000000..35fed9e --- /dev/null +++ b/templates/backoffice/help/nl/quiz_result.html.twig @@ -0,0 +1,5 @@ +
Resultaten
+

De tabel toont het eindresultaat per kandidaat gesorteerd op score. Rode rijen zijn de kandidaten met de laagste score die risico lopen op eliminatie.

+

Jokers voeg je toe voor goede of foute vragen (halve punten zijn mogelijk).

+

Straftijd is tijdstraf in seconden en wordt meegewogen bij gelijke score. Let op: Een positief getal is een straf en een negatief getal is een bonus.

+

Via Eliminatie voorbereiden stel je de schermkleuren handmatig in en start je de eliminatie.

diff --git a/templates/backoffice/help/nl/season_add.html.twig b/templates/backoffice/help/nl/season_add.html.twig new file mode 100644 index 0000000..7b0157a --- /dev/null +++ b/templates/backoffice/help/nl/season_add.html.twig @@ -0,0 +1,3 @@ +
Nieuw seizoen
+

Een seizoen groepeert alle testen en kandidaten voor één spel. Geef het seizoen een herkenbare naam, de seizoenscode wordt automatisch gegenereerd.

+

Na het aanmaken voeg je kandidaten toe en maak je testen aan via de seizoenpagina.

diff --git a/templates/backoffice/help/nl/season_add_candidates.html.twig b/templates/backoffice/help/nl/season_add_candidates.html.twig new file mode 100644 index 0000000..f9f6713 --- /dev/null +++ b/templates/backoffice/help/nl/season_add_candidates.html.twig @@ -0,0 +1,3 @@ +
Kandidaten toevoegen
+

Voer één naam per regel in. Dit zijn de spelers die deelnemen aan dit seizoen.

+

Je kunt later altijd nog kandidaten toevoegen via het tabblad Kandidaten. Gebruik dezelfde schrijfwijze van namen die je in het spel gebruikt.

diff --git a/templates/backoffice/help/nl/season_candidates.html.twig b/templates/backoffice/help/nl/season_candidates.html.twig new file mode 100644 index 0000000..bfabbb6 --- /dev/null +++ b/templates/backoffice/help/nl/season_candidates.html.twig @@ -0,0 +1,3 @@ +
Kandidaten
+

Dit zijn de spelers van dit seizoen. Voeg alle deelnemers toe voordat je de eerste test start, kandidaten worden automatisch aan nieuwe testen gekoppeld.

+

Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.

diff --git a/templates/backoffice/help/nl/season_question_bank.html.twig b/templates/backoffice/help/nl/season_question_bank.html.twig new file mode 100644 index 0000000..94a1e6f --- /dev/null +++ b/templates/backoffice/help/nl/season_question_bank.html.twig @@ -0,0 +1,3 @@ +
Vragenbank
+

De vragenbank is een bibliotheek met vragen die aan meerdere testen kunnen worden gekoppeld. Markeer een vraag als herbruikbaar als deze in meerdere testen mag voorkomen (bijv. "Wie is de Mol?").

+

Na het bewerken van een vraag in de bank worden testen die de vraag al bevatten niet automatisch bijgewerkt, gebruik de synchronisatieknop (↻) naast een test om de meest recente versie door te zetten.

diff --git a/templates/backoffice/help/nl/season_settings.html.twig b/templates/backoffice/help/nl/season_settings.html.twig new file mode 100644 index 0000000..9b86a80 --- /dev/null +++ b/templates/backoffice/help/nl/season_settings.html.twig @@ -0,0 +1,3 @@ +
Seizoensinstellingen
+

Pas hier de weergave-instellingen van dit seizoen aan.

+

Nummers tonen: toont vraagnummers tijdens de test. Antwoord bevestigen: vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.

diff --git a/templates/backoffice/help/nl/season_tests.html.twig b/templates/backoffice/help/nl/season_tests.html.twig new file mode 100644 index 0000000..bbe0c8c --- /dev/null +++ b/templates/backoffice/help/nl/season_tests.html.twig @@ -0,0 +1,11 @@ +
Testen beheren
+

Voeg een test toe vanuit een Excel-bestand of maak een lege test aan en vul deze via de vragenbank. Open daarna de test om hem te bekijken en af te ronden.

+

Een test moet eerst afgerond zijn voordat je hem kunt activeren. Slechts één test kan tegelijk actief zijn, namelijk de test die kandidaten op dat moment kunnen invullen.

+
Volgorde van werken
+
    +
  1. Test aanmaken (Excel of leeg)
  2. +
  3. Vragen controleren en test afronden
  4. +
  5. Test activeren
  6. +
  7. Kandidaten laten deelnemen
  8. +
  9. Resultaten bekijken en eliminatie voorbereiden
  10. +
diff --git a/templates/backoffice/help/prepare_elimination.html.twig b/templates/backoffice/help/prepare_elimination.html.twig new file mode 100644 index 0000000..a0fe68a --- /dev/null +++ b/templates/backoffice/help/prepare_elimination.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/prepare_elimination.html.twig', + 'backoffice/help/nl/prepare_elimination.html.twig', +]) }} diff --git a/templates/backoffice/help/quiz_add.html.twig b/templates/backoffice/help/quiz_add.html.twig new file mode 100644 index 0000000..a121f46 --- /dev/null +++ b/templates/backoffice/help/quiz_add.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/quiz_add.html.twig', + 'backoffice/help/nl/quiz_add.html.twig', +]) }} diff --git a/templates/backoffice/help/quiz_add_blank.html.twig b/templates/backoffice/help/quiz_add_blank.html.twig new file mode 100644 index 0000000..d4a1caa --- /dev/null +++ b/templates/backoffice/help/quiz_add_blank.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/quiz_add_blank.html.twig', + 'backoffice/help/nl/quiz_add_blank.html.twig', +]) }} diff --git a/templates/backoffice/help/quiz_answer_mapping.html.twig b/templates/backoffice/help/quiz_answer_mapping.html.twig new file mode 100644 index 0000000..bda4606 --- /dev/null +++ b/templates/backoffice/help/quiz_answer_mapping.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/quiz_answer_mapping.html.twig', + 'backoffice/help/nl/quiz_answer_mapping.html.twig', +]) }} diff --git a/templates/backoffice/help/quiz_candidates.html.twig b/templates/backoffice/help/quiz_candidates.html.twig new file mode 100644 index 0000000..fb26c4a --- /dev/null +++ b/templates/backoffice/help/quiz_candidates.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/quiz_candidates.html.twig', + 'backoffice/help/nl/quiz_candidates.html.twig', +]) }} diff --git a/templates/backoffice/help/quiz_overview.html.twig b/templates/backoffice/help/quiz_overview.html.twig new file mode 100644 index 0000000..b23508c --- /dev/null +++ b/templates/backoffice/help/quiz_overview.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/quiz_overview.html.twig', + 'backoffice/help/nl/quiz_overview.html.twig', +]) }} diff --git a/templates/backoffice/help/quiz_question_bank_form.html.twig b/templates/backoffice/help/quiz_question_bank_form.html.twig new file mode 100644 index 0000000..597d400 --- /dev/null +++ b/templates/backoffice/help/quiz_question_bank_form.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/quiz_question_bank_form.html.twig', + 'backoffice/help/nl/quiz_question_bank_form.html.twig', +]) }} diff --git a/templates/backoffice/help/quiz_result.html.twig b/templates/backoffice/help/quiz_result.html.twig new file mode 100644 index 0000000..40afbf9 --- /dev/null +++ b/templates/backoffice/help/quiz_result.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/quiz_result.html.twig', + 'backoffice/help/nl/quiz_result.html.twig', +]) }} diff --git a/templates/backoffice/help/season_add.html.twig b/templates/backoffice/help/season_add.html.twig new file mode 100644 index 0000000..322b01f --- /dev/null +++ b/templates/backoffice/help/season_add.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/season_add.html.twig', + 'backoffice/help/nl/season_add.html.twig', +]) }} diff --git a/templates/backoffice/help/season_add_candidates.html.twig b/templates/backoffice/help/season_add_candidates.html.twig new file mode 100644 index 0000000..f843541 --- /dev/null +++ b/templates/backoffice/help/season_add_candidates.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/season_add_candidates.html.twig', + 'backoffice/help/nl/season_add_candidates.html.twig', +]) }} diff --git a/templates/backoffice/help/season_candidates.html.twig b/templates/backoffice/help/season_candidates.html.twig new file mode 100644 index 0000000..882a5e1 --- /dev/null +++ b/templates/backoffice/help/season_candidates.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/season_candidates.html.twig', + 'backoffice/help/nl/season_candidates.html.twig', +]) }} diff --git a/templates/backoffice/help/season_question_bank.html.twig b/templates/backoffice/help/season_question_bank.html.twig new file mode 100644 index 0000000..11e215e --- /dev/null +++ b/templates/backoffice/help/season_question_bank.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/season_question_bank.html.twig', + 'backoffice/help/nl/season_question_bank.html.twig', +]) }} diff --git a/templates/backoffice/help/season_settings.html.twig b/templates/backoffice/help/season_settings.html.twig new file mode 100644 index 0000000..adafc3d --- /dev/null +++ b/templates/backoffice/help/season_settings.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/season_settings.html.twig', + 'backoffice/help/nl/season_settings.html.twig', +]) }} diff --git a/templates/backoffice/help/season_tests.html.twig b/templates/backoffice/help/season_tests.html.twig new file mode 100644 index 0000000..7adde10 --- /dev/null +++ b/templates/backoffice/help/season_tests.html.twig @@ -0,0 +1,4 @@ +{{ include([ + 'backoffice/help/' ~ app.request.locale ~ '/season_tests.html.twig', + 'backoffice/help/nl/season_tests.html.twig', +]) }} diff --git a/templates/backoffice/index.html.twig b/templates/backoffice/index.html.twig index 4fd6c99..5926749 100644 --- a/templates/backoffice/index.html.twig +++ b/templates/backoffice/index.html.twig @@ -11,53 +11,60 @@ {% endblock %} {% block body %} -
-

- {{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }} -

- - {{ 'Add'|trans }} - -
- {% if seasons %} - - - - {% if is_granted('ROLE_ADMIN') %} - - {% endif %} - - - - - - - - {% for season in seasons %} - - {% if is_granted('ROLE_ADMIN') %} - - {% endif %} - - + + + + + + + {% for season in seasons %} + + {% if is_granted('ROLE_ADMIN') %} + + {% endif %} + + + + + + {% endfor %} + +
{{ 'Owner(s)'|trans }}{{ 'Name'|trans }}{{ 'Active Quiz'|trans }}{{ 'Season Code'|trans }}{{ 'Manage'|trans }}
{{ season.owners|map(o => o.email)|join(', ') }}{{ season.name }} - {% if season.activeQuiz %} - {{ season.activeQuiz.name }} - {% else %} - {{ 'No active quiz'|trans }} +
+
+
+

+ {{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }} +

+ + {{ 'Add'|trans }} + +
+ {% if seasons %} + + + + {% if is_granted('ROLE_ADMIN') %} + {% endif %} - - - - - {% endfor %} - -
{{ 'Owner(s)'|trans }} - {{ season.seasonCode }} - - {{ 'Manage'|trans }} -
- {% else %} - {{ 'You have no seasons yet.'|trans }} - {% endif %} +
{{ 'Name'|trans }}{{ 'Active Quiz'|trans }}{{ 'Season Code'|trans }}{{ 'Manage'|trans }}
{{ season.owners|map(o => o.email)|join(', ') }}{{ season.name }} + {% if season.activeQuiz %} + {{ season.activeQuiz.name }} + {% else %} + {{ 'No active quiz'|trans }} + {% endif %} + + {{ season.seasonCode }} + + {{ 'Manage'|trans }} +
+ {% else %} + {{ 'You have no seasons yet.'|trans }} + {% endif %} + +
+ {{ include('backoffice/help/index.html.twig') }} +
+ {% endblock %} diff --git a/templates/backoffice/partials/answer_row.html.twig b/templates/backoffice/partials/answer_row.html.twig new file mode 100644 index 0000000..a3b633e --- /dev/null +++ b/templates/backoffice/partials/answer_row.html.twig @@ -0,0 +1,16 @@ +{% macro answer_row(answerForm) %} +
+ {{ form_widget(answerForm.ordering) }} + +
{{ form_widget(answerForm.text) }}
+
{{ form_widget(answerForm.isRightAnswer) }}
+ + +
+{% endmacro %} diff --git a/templates/backoffice/prepare_elimination/index.html.twig b/templates/backoffice/prepare_elimination/index.html.twig index bf16ede..61232a1 100644 --- a/templates/backoffice/prepare_elimination/index.html.twig +++ b/templates/backoffice/prepare_elimination/index.html.twig @@ -40,7 +40,7 @@
-

{{ 'Help text for preparing elimination'|trans }}

+ {{ include('backoffice/help/prepare_elimination.html.twig') }}
{% endblock %} diff --git a/templates/backoffice/question_bank/form.html.twig b/templates/backoffice/question_bank/form.html.twig new file mode 100644 index 0000000..2ace58f --- /dev/null +++ b/templates/backoffice/question_bank/form.html.twig @@ -0,0 +1,53 @@ +{% extends 'backoffice/base.html.twig' %} +{% import 'backoffice/partials/answer_row.html.twig' as macros %} + +{% 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 %} + {{ macros.answer_row(answerForm) }} + {% endfor %} +
+ {% do form.answers.setRendered %} +
+ + + +
+
+ + {{ form_end(form) }} +
+
+ {{ include('backoffice/help/quiz_question_bank_form.html.twig') }} +
+
+{% endblock body %} diff --git a/templates/backoffice/quiz/question_form.html.twig b/templates/backoffice/quiz/question_form.html.twig new file mode 100644 index 0000000..81ddfac --- /dev/null +++ b/templates/backoffice/quiz/question_form.html.twig @@ -0,0 +1,51 @@ +{% extends 'backoffice/base.html.twig' %} +{% import 'backoffice/partials/answer_row.html.twig' as macros %} + +{% block title %}{{ parent() }}{{ 'Edit question'|trans }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block body %} +
+
+

{{ 'Edit question'|trans }}

+ + {{ form_start(form) }} + {{ form_row(form.question) }} + +
+ {{ form_label(form.answers) }} + {{ form_errors(form.answers) }} +
+ {% for answerForm in form.answers %} + {{ macros.answer_row(answerForm) }} + {% endfor %} +
+ {% do form.answers.setRendered %} +
+ + + +
+
+ + {{ form_end(form) }} +
+
+ {{ include('backoffice/help/quiz_question_bank_form.html.twig') }} +
+
+{% endblock body %} diff --git a/templates/backoffice/quiz/tab_candidates.html.twig b/templates/backoffice/quiz/tab_candidates.html.twig index 20c6cc9..9db3c91 100644 --- a/templates/backoffice/quiz/tab_candidates.html.twig +++ b/templates/backoffice/quiz/tab_candidates.html.twig @@ -1,3 +1,5 @@ +
+
{% set questions = quiz.questions %} @@ -64,3 +66,8 @@ +
+
+ {{ include('backoffice/help/quiz_answer_mapping.html.twig') }} +
+
diff --git a/templates/backoffice/quiz/tab_candidates_list.html.twig b/templates/backoffice/quiz/tab_candidates_list.html.twig index 19883ce..3db6809 100644 --- a/templates/backoffice/quiz/tab_candidates_list.html.twig +++ b/templates/backoffice/quiz/tab_candidates_list.html.twig @@ -1,3 +1,5 @@ +
+

{{ 'Candidates'|trans }}

@@ -50,3 +52,8 @@ {% endfor %}
+
+
+ {{ include('backoffice/help/quiz_candidates.html.twig') }} +
+
diff --git a/templates/backoffice/quiz/tab_overview.html.twig b/templates/backoffice/quiz/tab_overview.html.twig index adf01be..99d1423 100644 --- a/templates/backoffice/quiz/tab_overview.html.twig +++ b/templates/backoffice/quiz/tab_overview.html.twig @@ -22,13 +22,24 @@
{% 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,24 +47,44 @@ {% else %} -
{% endif %} + {% if not quiz.isFinalized %} +
+ + +
+ {% elseif not quiz.hasStartedCandidates and quiz is not same as (season.activeQuiz) %} +
+ + +
+ {% endif %} - + + {{ 'Export to XLSX'|trans }} +
- - {{ 'Export to XLSX'|trans }} - -

{{ 'Questions'|trans }}

{%~ for question in quiz.questions ~%} @@ -73,6 +104,12 @@
+ {% if is_granted('QUIZ_MODIFY_CONTENT', question) %} + + {{ 'Edit'|trans }} + + {% endif %}
    {%~ for answer in question.answers %} @@ -111,3 +148,7 @@ csrf_token('delete_quiz'), ) }}
+
+ {{ include('backoffice/help/quiz_overview.html.twig') }} +
+
diff --git a/templates/backoffice/quiz/tab_result.html.twig b/templates/backoffice/quiz/tab_result.html.twig index cef5e6e..30578ad 100644 --- a/templates/backoffice/quiz/tab_result.html.twig +++ b/templates/backoffice/quiz/tab_result.html.twig @@ -1,3 +1,5 @@ +
+

{{ 'Score'|trans }}

diff --git a/templates/backoffice/quiz_add.html.twig b/templates/backoffice/quiz_add.html.twig index e9553e0..a2d6de7 100644 --- a/templates/backoffice/quiz_add.html.twig +++ b/templates/backoffice/quiz_add.html.twig @@ -21,9 +21,7 @@ {{ form_end(form) }}
-

- {{ 'Help text for adding a quiz'|trans }} -

+ {{ include('backoffice/help/quiz_add.html.twig') }}
{% endblock %} diff --git a/templates/backoffice/quiz_add_blank.html.twig b/templates/backoffice/quiz_add_blank.html.twig new file mode 100644 index 0000000..76009ea --- /dev/null +++ b/templates/backoffice/quiz_add_blank.html.twig @@ -0,0 +1,28 @@ +{% extends 'backoffice/base.html.twig' %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block body %} +
+
+

{{ t('Add a quiz to {name}', {name: season.name})|trans }}

+ {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }} + {{ form_end(form) }} +
+
+ {{ include('backoffice/help/quiz_add_blank.html.twig') }} +
+
+{% endblock %} + +{% block title %}{{ parent() }}Backoffice{% endblock %} 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..70f9a95 --- /dev/null +++ b/templates/backoffice/season/tab_candidates.html.twig @@ -0,0 +1,18 @@ +
+
+ +
    + {% for candidate in season.candidates %} +
  • {{ candidate.name }}
  • + {% else %} + {{ 'No candidates'|trans }} + {% endfor %} +
+
+
+ {{ include('backoffice/help/season_candidates.html.twig') }} +
+
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..75f9467 --- /dev/null +++ b/templates/backoffice/season/tab_question_bank.html.twig @@ -0,0 +1,170 @@ +
+
+ + +
+ {{ 'All'|trans }} + {% for label in season.questionLabels %} + + {{ label.name }} +
+ + +
+
+ {% endfor %} + + {% embed 'components/modal.html.twig' with { + id: 'addLabelModal', + title: 'Add label'|trans, + triggerLabel: 'Add label'|trans, + } %} + {% block modal_body %} +
+ +
+ + +
+
+ + +
+ {% for colour in labelColours %} + + + {% endfor %} +
+
+
+ {% endblock %} + {% block modal_footer %} + + + {% endblock %} + {% endembed %} +
+ + + + + + + + + + + + + {% 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 %} + + {% for usage in bankQuestion.usages %} +
+ {{ usage.quiz.name }} +
+ + +
+ {% if usage.quiz.isFinalized %} +
+ + +
+ {% endif %} +
+ {% endfor %} +
+
+ {% if bankQuestion.canBeAssigned and assignableQuizzes|length > 0 %} +
+ + + +
+ {% endif %} +
+ + +
+
+ + +
{{ 'No questions in the question bank yet'|trans }}
+
+
+ {{ include('backoffice/help/season_question_bank.html.twig') }} +
+
diff --git a/templates/backoffice/season/tab_settings.html.twig b/templates/backoffice/season/tab_settings.html.twig new file mode 100644 index 0000000..160e858 --- /dev/null +++ b/templates/backoffice/season/tab_settings.html.twig @@ -0,0 +1,8 @@ +
+
+ {{ form(form) }} +
+
+ {{ include('backoffice/help/season_settings.html.twig') }} +
+
diff --git a/templates/backoffice/season/tab_tests.html.twig b/templates/backoffice/season/tab_tests.html.twig new file mode 100644 index 0000000..49d650b --- /dev/null +++ b/templates/backoffice/season/tab_tests.html.twig @@ -0,0 +1,26 @@ +
+
+ +
+ {% for quiz in season.quizzes %} + + {{ quiz.name }} + {% if quiz.isFinalized %} + {{ 'Finalized'|trans }} + {% endif %} + + {% else %} + {{ 'No quizzes'|trans }} + {% endfor %} +
+
+
+ {{ include('backoffice/help/season_tests.html.twig') }} +
+
diff --git a/templates/backoffice/season_add.html.twig b/templates/backoffice/season_add.html.twig index a907712..17c37b3 100644 --- a/templates/backoffice/season_add.html.twig +++ b/templates/backoffice/season_add.html.twig @@ -19,9 +19,7 @@ {{ form_end(form) }}
-

- {{ 'Help text for creating a season'|trans }} -

+ {{ include('backoffice/help/season_add.html.twig') }}
{% endblock %} diff --git a/templates/backoffice/season_add_candidates.html.twig b/templates/backoffice/season_add_candidates.html.twig index 319263c..1fd1223 100644 --- a/templates/backoffice/season_add_candidates.html.twig +++ b/templates/backoffice/season_add_candidates.html.twig @@ -20,9 +20,7 @@ {{ form_end(form) }}
-

- {{ 'Help text for adding candidates'|trans }} -

+ {{ include('backoffice/help/season_add_candidates.html.twig') }}
{% endblock %} diff --git a/templates/components/modal.html.twig b/templates/components/modal.html.twig new file mode 100644 index 0000000..b6d702e --- /dev/null +++ b/templates/components/modal.html.twig @@ -0,0 +1,25 @@ +{% block modal_trigger %} + +{% endblock %} + + diff --git a/tests/Controller/Backoffice/QuestionBankControllerTest.php b/tests/Controller/Backoffice/QuestionBankControllerTest.php new file mode 100644 index 0000000..dfd3bfa --- /dev/null +++ b/tests/Controller/Backoffice/QuestionBankControllerTest.php @@ -0,0 +1,328 @@ +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->slug); + + $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 testCreateAllowedWithoutCorrectAnswer(): 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->assertResponseRedirects(); + $saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']); + $this->assertInstanceOf(BankQuestion::class, $saved); + $this->assertFalse($saved->isCompleteForQuiz); + } + + 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->slug)); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [ + '_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/QuizControllerTest.php b/tests/Controller/Backoffice/QuizControllerTest.php new file mode 100644 index 0000000..0e65754 --- /dev/null +++ b/tests/Controller/Backoffice/QuizControllerTest.php @@ -0,0 +1,345 @@ +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 getCandidate(string $name): Candidate + { + $candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]); + $this->assertInstanceOf(Candidate::class, $candidate); + + return $candidate; + } + + private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string + { + $crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); + self::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 testIndexRedirectsToOverview(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s', $quiz->id)); + + self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); + } + + public function testOverviewLoadsSuccessfully(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Quiz 1'); + } + + public function testResultTabLoadsSuccessfully(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id)); + + self::assertResponseIsSuccessful(); + } + + public function testCandidatesTabLoadsSuccessfully(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id)); + + self::assertResponseIsSuccessful(); + } + + public function testAnswerMappingRedirectsToFirstQuestion(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/answer-mapping', $quiz->id)); + + self::assertResponseRedirects(); + $this->assertStringContainsString('/candidates/', (string) $this->client->getResponse()->headers->get('Location')); + } + + public function testCandidatesQuestionTabLoadsSuccessfully(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + $question = $quiz->questions->first(); + $this->assertInstanceOf(Question::class, $question); + + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates/%s', $quiz->id, $question->id)); + + self::assertResponseIsSuccessful(); + } + + public function testSaveCandidateAnswersPersistsSelection(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + $question = $quiz->questions->first(); + $this->assertInstanceOf(Question::class, $question); + $answer = $question->answers->first(); + $this->assertInstanceOf(Answer::class, $answer); + $candidate = $this->getCandidate('Tom'); + + $url = \sprintf('/backoffice/season/krtek/quiz/%s/candidates/%s', $quiz->id, $question->id); + $crawler = $this->client->request(Request::METHOD_GET, $url); + self::assertResponseIsSuccessful(); + $token = (string) $crawler->filter('input[name="_token"]')->first()->attr('value'); + + $this->client->request(Request::METHOD_POST, $url, [ + '_token' => $token, + 'candidate_answer' => [ + (string) $candidate->id => [(string) $answer->id], + ], + ]); + + $this->assertResponseRedirects($url); + $this->entityManager->clear(); + + $savedAnswer = $this->entityManager->getRepository(Answer::class)->find($answer->id); + $this->assertInstanceOf(Answer::class, $savedAnswer); + $this->assertTrue($savedAnswer->candidates->exists( + static fn (int $key, Candidate $c): bool => $c->id->equals($candidate->id), + )); + } + + public function testToggleCandidateCreatesInactiveQuizCandidate(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + $candidate = $this->getCandidate('Tom'); + + $crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id)); + self::assertResponseIsSuccessful(); + $token = (string) $crawler->filter(\sprintf('form[action*="/%s/toggle"] input[name="_token"]', $candidate->id))->first()->attr('value'); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/toggle', $quiz->id, $candidate->id), [ + '_token' => $token, + ]); + + self::assertResponseRedirects(); + $this->entityManager->clear(); + + $quizCandidate = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([ + 'quiz' => $this->getQuizByName('Quiz 1'), + 'candidate' => $this->getCandidate('Tom'), + ]); + $this->assertInstanceOf(QuizCandidate::class, $quizCandidate); + $this->assertFalse($quizCandidate->active); + } + + public function testToggleCandidateTogglesActiveState(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + $candidate = $this->getCandidate('Tom'); + + $quizCandidate = new QuizCandidate($quiz, $candidate); + $quizCandidate->active = false; + + $this->entityManager->persist($quizCandidate); + $this->entityManager->flush(); + + $crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id)); + $token = (string) $crawler->filter(\sprintf('form[action*="/%s/toggle"] input[name="_token"]', $candidate->id))->first()->attr('value'); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/toggle', $quiz->id, $candidate->id), [ + '_token' => $token, + ]); + + self::assertResponseRedirects(); + $this->entityManager->clear(); + + $updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([ + 'quiz' => $this->getQuizByName('Quiz 1'), + 'candidate' => $this->getCandidate('Tom'), + ]); + $this->assertInstanceOf(QuizCandidate::class, $updated); + $this->assertTrue($updated->active); + } + + public function testModifyCorrection(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + $candidate = $this->getCandidate('Tom'); + + // getScores() requires started IS NOT NULL and at least one GivenAnswer + $quizCandidate = new QuizCandidate($quiz, $candidate); + $quizCandidate->started = new DateTimeImmutable(); + + $this->entityManager->persist($quizCandidate); + $firstQuestion = $quiz->questions->first(); + $this->assertInstanceOf(Question::class, $firstQuestion); + $answer = $firstQuestion->answers->first(); + $this->assertInstanceOf(Answer::class, $answer); + $this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer)); + $this->entityManager->flush(); + + $crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id)); + self::assertResponseIsSuccessful(); + $token = (string) $crawler->filter(\sprintf('form[action*="%s/modify_correction"] input[name="_token"]', $candidate->id))->first()->attr('value'); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/modify_correction', $quiz->id, $candidate->id), [ + '_token' => $token, + 'corrections' => '1.5', + ]); + + self::assertResponseRedirects(); + $this->entityManager->clear(); + + $updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([ + 'quiz' => $this->getQuizByName('Quiz 1'), + 'candidate' => $this->getCandidate('Tom'), + ]); + $this->assertInstanceOf(QuizCandidate::class, $updated); + $this->assertEqualsWithDelta(1.5, $updated->corrections, \PHP_FLOAT_EPSILON); + } + + public function testModifyPenalty(): void + { + $quiz = $this->getQuizByName('Quiz 1'); + $candidate = $this->getCandidate('Claudia'); + + $quizCandidate = new QuizCandidate($quiz, $candidate); + $quizCandidate->started = new DateTimeImmutable(); + + $this->entityManager->persist($quizCandidate); + $firstQuestion = $quiz->questions->first(); + $this->assertInstanceOf(Question::class, $firstQuestion); + $answer = $firstQuestion->answers->first(); + $this->assertInstanceOf(Answer::class, $answer); + $this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer)); + $this->entityManager->flush(); + + $crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id)); + self::assertResponseIsSuccessful(); + $token = (string) $crawler->filter(\sprintf('form[action*="%s/modify_penalty"] input[name="_token"]', $candidate->id))->first()->attr('value'); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/modify_penalty', $quiz->id, $candidate->id), [ + '_token' => $token, + 'penalty' => '30', + ]); + + self::assertResponseRedirects(); + $this->entityManager->clear(); + + $updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([ + 'quiz' => $this->getQuizByName('Quiz 1'), + 'candidate' => $this->getCandidate('Claudia'), + ]); + $this->assertInstanceOf(QuizCandidate::class, $updated); + $this->assertSame(30, $updated->penaltySeconds); + } + + public function testDeleteQuiz(): void + { + $quiz = $this->getQuizByName('Quiz 2'); + $quizId = $quiz->id; + $token = $this->getCsrfTokenFromOverview($quiz, '/delete'); + + $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/delete', $quiz->id), [ + '_token' => $token, + ]); + + self::assertResponseRedirects('/backoffice/season/krtek'); + $this->entityManager->clear(); + $this->assertNotInstanceOf(Quiz::class, $this->entityManager->getRepository(Quiz::class)->find($quizId)); + } + + public function testNonOwnerIsDenied(): void + { + $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); + $this->assertInstanceOf(User::class, $user); + $this->client->loginUser($user); + + $quiz = $this->getQuizByName('Quiz 1'); + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); + + self::assertResponseStatusCodeSame(403); + } + + public function testOverviewLoadsForEmptyQuiz(): void + { + $season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']); + $this->assertInstanceOf(Season::class, $season); + + $emptyQuiz = new Quiz(); + $emptyQuiz->name = 'Empty Quiz'; + + $season->addQuiz($emptyQuiz); + $this->entityManager->persist($emptyQuiz); + $this->entityManager->flush(); + + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $emptyQuiz->id)); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Empty Quiz'); + } + + public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void + { + $season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']); + $this->assertInstanceOf(Season::class, $season); + + $emptyQuiz = new Quiz(); + $emptyQuiz->name = 'Empty Quiz'; + + $season->addQuiz($emptyQuiz); + $this->entityManager->persist($emptyQuiz); + $this->entityManager->flush(); + + $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/answer-mapping', $emptyQuiz->id)); + + self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $emptyQuiz->id)); + $this->client->followRedirect(); + self::assertSelectorTextContains('body', 'Deze test heeft nog geen vragen'); + } +} diff --git a/tests/Controller/Backoffice/QuizFinalizeTest.php b/tests/Controller/Backoffice/QuizFinalizeTest.php new file mode 100644 index 0000000..2e3e5e8 --- /dev/null +++ b/tests/Controller/Backoffice/QuizFinalizeTest.php @@ -0,0 +1,234 @@ +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); + } + + public function testDeactivateWithRedirectQuizStaysOnQuizOverview(): void + { + // Quiz 1 is active in fixtures; deactivate while viewing Quiz 2 → should redirect to Quiz 2 overview + $this->getQuizByName('Quiz 1'); + $quiz2 = $this->getQuizByName('Quiz 2'); + + $token = $this->getCsrfTokenFromOverview($quiz2, '/enable'); + $this->client->request( + Request::METHOD_POST, + '/backoffice/season/krtek/quiz/null/enable', + ['_token' => $token, 'redirect_quiz' => (string) $quiz2->id], + ); + + self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id)); + + $this->entityManager->clear(); + $this->assertNotInstanceOf(Quiz::class, $this->getKrtekSeason()->activeQuiz); + } +} 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..b1ac1f8 100644 --- a/tests/Security/Voter/SeasonVoterTest.php +++ b/tests/Security/Voter/SeasonVoterTest.php @@ -8,13 +8,16 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; +use Safe\DateTimeImmutable; 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 +78,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 = new Quiz(); + $quiz->season = $season; + // finalizedAt = null, candidateData empty → isLocked = 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 = new Quiz(); + $quiz->season = $season; + $quiz->finalizedAt = new DateTimeImmutable(); // → isLocked = 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 = new Quiz(); + $quiz->season = self::createStub(Season::class); + $quiz->finalizedAt = new DateTimeImmutable(); // → isLocked = 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..c860042 100644 --- a/translations/messages+intl-icu.nl.xliff +++ b/translations/messages+intl-icu.nl.xliff @@ -5,6 +5,14 @@ + + A label with a similar name already exists + Er bestaat al een label met deze naam + + + A quiz with this name already exists in this season + Er bestaat al een test met deze naam in dit seizoen + Actions Acties @@ -33,6 +41,10 @@ Add Candidates Voeg kandidaten toe + + Add Empty Quiz + Voeg een lege test toe + Add Quiz Test toevoegen @@ -41,6 +53,30 @@ Add a quiz to {name} Voeg een test toe aan {name} + + Add answer + Antwoord toevoegen + + + Add blank + Leeg toevoegen + + + Add blank quiz + Lege quiz toevoegen + + + Add label + Label toevoegen + + + Add question + Vraag toevoegen + + + All + Alle + All Seasons Alle seizoenen @@ -57,14 +93,34 @@ 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 + + + Blue + Blauw + + + Cancel + Annuleren + Candidate Kandidaat @@ -93,6 +149,14 @@ Clear Quiz... Test leegmaken... + + Close + Sluiten + + + Colour + Kleur + Completed Voltooid @@ -125,6 +189,14 @@ Create an account Maak een account aan + + Create an empty quiz and add questions from the question bank. + Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank. + + + Cyan + Cyaan + Deactivate Deactiveren @@ -133,6 +205,14 @@ Deactivate Quiz Deactiveer test + + Deactivate the quiz before undoing the finalization + Deactiveer de test voordat je het afronden ongedaan maakt + + + Delete + Verwijderen + Delete Quiz... Test verwijderen... @@ -141,10 +221,26 @@ Download Template Download sjabloon + + Draft + Concept + + + Drag to reorder + Sleep om te sorteren + EMPTY LEEG + + Edit + Bewerken + + + Edit question + Vraag bewerken + Email E-mail @@ -161,6 +257,22 @@ Error clearing quiz Fout bij het leegmaken van de test + + Export to XLSX + Exporteren naar XLSX + + + Finalize + Afronden + + + Finalized + Afgerond + + + Gray + Grijs + Green Groen @@ -185,6 +297,14 @@ Home Home + + Import + Importeren + + + Import Quiz from Excel + Importeer test vanuit Excel + In Progress Bezig @@ -193,14 +313,38 @@ 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) + + + Locks the quiz so it can no longer be edited and makes it ready for candidates to take. + Vergrendelt de test zodat deze niet meer bewerkt kan worden en maakt deze klaar voor deelnemers. + Logout Uitloggen @@ -221,6 +365,10 @@ Name Naam + + New label + Nieuw label + Next Volgende @@ -233,6 +381,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 +405,10 @@ Number of dropouts: Aantal afvallers: + + Open + Openen + Overview Overzicht @@ -297,6 +457,38 @@ 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 quiz %quiz% + Vraag verwijderd uit quiz %quiz% + + + Question removed from the question bank + Vraag verwijderd uit de vragenbank + + + Question synced to quiz %quiz% + Vraag gesynchroniseerd naar quiz %quiz% + + + Question updated + Vraag bijgewerkt + Questions Vragen @@ -325,6 +517,10 @@ Quiz cleared Test leeggemaakt + + Quiz cleared and no longer finalized + Test leeggemaakt en niet langer afgerond + Quiz completed Test voltooid @@ -333,6 +529,14 @@ Quiz deleted Test verwijderd + + Quiz finalized + Test afgerond + + + Quiz is no longer finalized + Test is niet langer afgerond + Quiz name Testnaam @@ -341,6 +545,14 @@ Quizzes Tests + + Randomize + Husselen + + + Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again. + Heropent de test voor bewerking. Deelnemers kunnen de test niet meer afnemen totdat deze opnieuw is afgerond. + Red Rood @@ -353,6 +565,10 @@ Remember me Onthoud mij + + Remove label + Label verwijderen + Repeat Password Herhaal wachtwoord @@ -361,6 +577,10 @@ Results & Elimination + + Reusable + Herbruikbaar + Save Opslaan @@ -401,6 +621,10 @@ Sign in Log in + + Sort A–Z + Sorteer A-Z + Status Status @@ -409,10 +633,34 @@ Submit Verstuur + + Sync latest changes to this quiz + Laatste wijzigingen synchroniseren naar deze quiz + The password fields must match. De wachtwoorden moeten overeen komen. + + The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them. + De vraag is niet gesynchroniseerd naar afgeronde quiz(zes): %quizzes%. Gebruik de Synchroniseren-knop om ze bij te werken. + + + The quiz cannot be finalized while it has errors + De test kan niet afgerond 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 is already finalized + De test is al afgerond + + + The quiz must be finalized before it can be activated + De test moet afgerond zijn voordat deze geactiveerd kan worden + There are no answers for this question Er zijn geen antwoorden voor deze vraag @@ -421,10 +669,62 @@ There is no active quiz Er is geen test actief + + This question cannot be deleted because it is used in a locked or active quiz + Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test + + + 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 question is incomplete: it needs at least two answers and exactly one correct answer + Deze vraag is incompleet: er zijn tenminste twee antwoorden en precies één goed antwoord nodig + + + This quiz can no longer be altered + Deze test kan niet meer aangepast worden + + + This quiz has already been filled in and can no longer be altered + Deze quiz is al ingevuld en kan niet meer worden gewijzigd + + + This quiz has no questions yet + Deze test heeft nog geen vragen + Time Tijd + + Toggle correct answer + + + + Unassign + Ontkoppelen + + + Undo finalization + Afronden ongedaan maken + + + Used in + Gebruikt in + + + White + Wit + + + Yellow + Geel + 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.