mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-05 15:10:16 +02:00
feat: address PR review comments — unassign/sync bank questions, blank quiz creation, deactivate redirect, remove duplicate tab titles
- Use Symfony ObjectMapper for BankQuestion/BankAnswer → Question/Answer copy (#[Map(if: false)] on id, season, etc.) - Track created Question on BankQuestionUsage (nullable FK, onDelete: SET NULL) for unassign/sync support - Add unassign route: removes the Question copy + usage record - Add sync route: pushes bank question edits to a finalized-not-started quiz copy - Auto-sync non-finalized quiz copies on bank question edit; flash warning for finalized-not-started - Add blank quiz creation (no XLSX required) with new route + template - Deactivate quiz button now stays on the quiz overview page (redirect_quiz hidden field) - Remove duplicate h4 titles below the tab bar on all season tabs - Add migration for bank_question_usage.question_id - Add Dutch translations for all new strings
This commit is contained in:
@@ -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)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<int, QuestionLabel> */
|
||||
#[Map(if: false)]
|
||||
#[ORM\ManyToMany(targetEntity: QuestionLabel::class, inversedBy: 'bankQuestions')]
|
||||
public private(set) Collection $labels;
|
||||
|
||||
/** @var Collection<int, BankAnswer> */
|
||||
#[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<int, BankQuestionUsage> */
|
||||
#[Map(if: false)]
|
||||
#[ORM\OneToMany(targetEntity: BankQuestionUsage::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
|
||||
public private(set) Collection $usages;
|
||||
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user