diff --git a/migrations/Version20260704200000.php b/migrations/Version20260704200000.php new file mode 100644 index 0000000..23dc6f5 --- /dev/null +++ b/migrations/Version20260704200000.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE bank_question_usage ADD question_id UUID DEFAULT NULL'); + $this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_BQU_QUESTION FOREIGN KEY (question_id) REFERENCES question (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_BQU_QUESTION ON bank_question_usage (question_id)'); + } + + #[\Override] + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX IDX_BQU_QUESTION'); + $this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_BQU_QUESTION'); + $this->addSql('ALTER TABLE bank_question_usage DROP COLUMN question_id'); + } +} diff --git a/src/Controller/Backoffice/QuestionBankController.php b/src/Controller/Backoffice/QuestionBankController.php index 4002741..5729583 100644 --- a/src/Controller/Backoffice/QuestionBankController.php +++ b/src/Controller/Backoffice/QuestionBankController.php @@ -19,6 +19,7 @@ use Symfony\Component\Uid\Uuid; use Symfony\Contracts\Translation\TranslatorInterface; use Tvdt\Controller\AbstractController; use Tvdt\Entity\BankQuestion; +use Tvdt\Entity\BankQuestionUsage; use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; @@ -121,6 +122,8 @@ class QuestionBankController extends AbstractController $this->applyAnswerOrdering($bankQuestion); $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]); @@ -245,6 +248,64 @@ class QuestionBankController extends AbstractController 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->hasStartedCandidates()) { + $this->addFlash(FlashType::Danger, $this->translator->trans('This quiz has already been filled in and can no longer be altered')); + + return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); + } + + $this->questionBankService->syncToQuiz($bankQuestion, $usage); + $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) { @@ -259,4 +320,26 @@ class QuestionBankController extends AbstractController $answer->ordering = $ordering++; } } + + private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void + { + $pendingNames = []; + foreach ($bankQuestion->usages as $usage) { + if (!$usage->quiz->isFinalized()) { + $this->questionBankService->syncToQuiz($bankQuestion, $usage); + } elseif (!$usage->quiz->hasStartedCandidates()) { + $pendingNames[] = $usage->quiz->name; + } + } + + 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 52e6d26..59d8749 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -252,19 +252,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', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); + 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]); diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php index 292cef3..0141620 100644 --- a/src/Controller/Backoffice/SeasonController.php +++ b/src/Controller/Backoffice/SeasonController.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Tvdt\Controller\Backoffice; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -148,4 +150,38 @@ 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' => 'Quiz name']) + ->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); + $this->em->flush(); + + $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/Entity/BankAnswer.php b/src/Entity/BankAnswer.php index c06e49d..2d84910 100644 --- a/src/Entity/BankAnswer.php +++ b/src/Entity/BankAnswer.php @@ -7,11 +7,13 @@ namespace Tvdt\Entity; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; +use Symfony\Component\ObjectMapper\Attribute\Map; use Symfony\Component\Uid\Uuid; #[ORM\Entity] class BankAnswer implements \Stringable { + #[Map(if: false)] #[ORM\Column(type: UuidType::NAME)] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] #[ORM\GeneratedValue(strategy: 'CUSTOM')] @@ -21,6 +23,7 @@ class BankAnswer implements \Stringable #[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])] public int $ordering = 0; + #[Map(if: false)] #[ORM\JoinColumn(nullable: false)] #[ORM\ManyToOne(inversedBy: 'answers')] public BankQuestion $bankQuestion; diff --git a/src/Entity/BankQuestion.php b/src/Entity/BankQuestion.php index ec54c05..83b9124 100644 --- a/src/Entity/BankQuestion.php +++ b/src/Entity/BankQuestion.php @@ -8,6 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; +use Symfony\Component\ObjectMapper\Attribute\Map; use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -16,6 +17,7 @@ use Tvdt\Repository\BankQuestionRepository; #[ORM\Entity(repositoryClass: BankQuestionRepository::class)] class BankQuestion implements \Stringable { + #[Map(if: false)] #[ORM\Column(type: UuidType::NAME)] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] #[ORM\GeneratedValue(strategy: 'CUSTOM')] @@ -25,24 +27,29 @@ class BankQuestion implements \Stringable #[ORM\Column(length: 255)] public string $question; + #[Map(if: false)] #[ORM\JoinColumn(nullable: false)] #[ORM\ManyToOne(inversedBy: 'bankQuestions')] public Season $season; + #[Map(if: false)] #[ORM\Column(options: ['default' => false])] public bool $reusable = false; /** @var Collection */ + #[Map(if: false)] #[ORM\ManyToMany(targetEntity: QuestionLabel::class, inversedBy: 'bankQuestions')] public private(set) Collection $labels; /** @var Collection */ #[Assert\Count(min: 2, minMessage: 'A question needs at least two answers')] + #[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; diff --git a/src/Entity/BankQuestionUsage.php b/src/Entity/BankQuestionUsage.php index 3cbf138..bcca043 100644 --- a/src/Entity/BankQuestionUsage.php +++ b/src/Entity/BankQuestionUsage.php @@ -24,6 +24,10 @@ class BankQuestionUsage #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)] public private(set) \DateTimeImmutable $created; + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[ORM\ManyToOne] + public ?Question $question = null; + public function __construct( #[ORM\JoinColumn(nullable: false)] #[ORM\ManyToOne(inversedBy: 'usages')] diff --git a/src/Service/QuestionBankService.php b/src/Service/QuestionBankService.php index 7aac3e2..175d802 100644 --- a/src/Service/QuestionBankService.php +++ b/src/Service/QuestionBankService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tvdt\Service; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Tvdt\Entity\Answer; use Tvdt\Entity\BankQuestion; use Tvdt\Entity\BankQuestionUsage; @@ -15,7 +16,10 @@ use Tvdt\Exception\QuizLockedException; final readonly class QuestionBankService { - public function __construct(private EntityManagerInterface $entityManager) {} + public function __construct( + private EntityManagerInterface $entityManager, + private ObjectMapperInterface $objectMapper, + ) {} /** * Copy a bank question (with its answers) into a quiz and record the usage. @@ -42,20 +46,66 @@ final readonly class QuestionBankService $maxOrdering = max($maxOrdering, $existingQuestion->ordering); } - $question = new Question(); - $question->question = $bankQuestion->question; + /** @var Question $question */ + $question = $this->objectMapper->map($bankQuestion, Question::class); $question->ordering = $maxOrdering + 1; foreach ($bankQuestion->answers as $bankAnswer) { - $answer = new Answer($bankAnswer->text, $bankAnswer->isRightAnswer); - $answer->ordering = $bankAnswer->ordering; + /** @var Answer $answer */ + $answer = $this->objectMapper->map($bankAnswer, Answer::class); $question->addAnswer($answer); } $quiz->addQuestion($question); - $bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz)); + + $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); + } + + $this->entityManager->flush(); + } + + /** 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/quiz/tab_overview.html.twig b/templates/backoffice/quiz/tab_overview.html.twig index 1bed985..551a20b 100644 --- a/templates/backoffice/quiz/tab_overview.html.twig +++ b/templates/backoffice/quiz/tab_overview.html.twig @@ -38,6 +38,7 @@ {% if quiz is same as (season.activeQuiz) %}
+ diff --git a/templates/backoffice/quiz_add_blank.html.twig b/templates/backoffice/quiz_add_blank.html.twig new file mode 100644 index 0000000..66d0e1a --- /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) }} +
+
+

{{ 'Create an empty quiz and add questions from the question bank.'|trans }}

+
+
+{% endblock %} + +{% block title %}{{ parent() }}Backoffice{% endblock %} diff --git a/templates/backoffice/season/tab_candidates.html.twig b/templates/backoffice/season/tab_candidates.html.twig index 6681741..ac6788c 100644 --- a/templates/backoffice/season/tab_candidates.html.twig +++ b/templates/backoffice/season/tab_candidates.html.twig @@ -1,10 +1,8 @@
-
-

{{ 'Candidates'|trans }}

- {{ 'Add Candidate'|trans }} - +
    {% for candidate in season.candidates %} diff --git a/templates/backoffice/season/tab_question_bank.html.twig b/templates/backoffice/season/tab_question_bank.html.twig index 7351199..96eac18 100644 --- a/templates/backoffice/season/tab_question_bank.html.twig +++ b/templates/backoffice/season/tab_question_bank.html.twig @@ -1,6 +1,5 @@ -
    -

    {{ 'Question bank'|trans }}

    - {{ 'Add'|trans }} +
    @@ -51,7 +50,23 @@ {% endif %} - {{ bankQuestion.usages|map(usage => usage.quiz.name)|join(', ') }} + {% for usage in bankQuestion.usages %} +
    + {{ usage.quiz.name }} + + + + + {% if usage.quiz.isFinalized %} +
    + + +
    + {% endif %} +
    + {% endfor %}
    diff --git a/templates/backoffice/season/tab_settings.html.twig b/templates/backoffice/season/tab_settings.html.twig index 8d638a7..a55147d 100644 --- a/templates/backoffice/season/tab_settings.html.twig +++ b/templates/backoffice/season/tab_settings.html.twig @@ -1,6 +1,5 @@
    -

    {{ 'Settings'|trans }}

    {{ form(form) }}
    diff --git a/templates/backoffice/season/tab_tests.html.twig b/templates/backoffice/season/tab_tests.html.twig index 2e8c00b..8a4b415 100644 --- a/templates/backoffice/season/tab_tests.html.twig +++ b/templates/backoffice/season/tab_tests.html.twig @@ -1,9 +1,10 @@
    -
    -

    {{ 'Quizzes'|trans }}

    - {{ 'Add'|trans }} +
    {% for quiz in season.quizzes %} diff --git a/translations/messages+intl-icu.nl.xliff b/translations/messages+intl-icu.nl.xliff index d3150cb..5f299a4 100644 --- a/translations/messages+intl-icu.nl.xliff +++ b/translations/messages+intl-icu.nl.xliff @@ -45,6 +45,18 @@ Add answer Antwoord toevoegen + + Add blank + Leeg toevoegen + + + Add blank quiz + Lege quiz toevoegen + + + Add from XLSX + Toevoegen vanuit XLSX + Add label Label toevoegen @@ -153,6 +165,10 @@ 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. + Deactivate Deactiveren @@ -409,10 +425,18 @@ 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 @@ -549,10 +573,18 @@ 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 gefinaliseerde 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 gefinaliseerd worden zolang er fouten zijn @@ -585,10 +617,18 @@ 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 + Time Tijd + + Unassign + Ontkoppelen + Undo finalization Finalisatie ongedaan maken