mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-07 16:10:15 +02:00
cb577d60d3
Enable @hotwired/turbo with Drive explicitly disabled, then migrate the bo--modal-form Stimulus controller (custom fetch + X-Modal-Request pattern) to a thin bo--modal controller that lets Turbo handle HTTP and DOM swap. Adds frame templates for quiz questions and bank questions; controllers now detect Turbo-Frame header instead of X-Modal-Request.
420 lines
17 KiB
PHP
420 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tvdt\Controller\Backoffice;
|
|
|
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Routing\Requirement\Requirement;
|
|
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
use Symfony\Component\String\Slugger\SluggerInterface;
|
|
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;
|
|
use Tvdt\Enum\FlashType;
|
|
use Tvdt\Enum\LabelColour;
|
|
use Tvdt\Exception\BankQuestionAlreadyUsedException;
|
|
use Tvdt\Exception\BankQuestionIncompleteException;
|
|
use Tvdt\Exception\QuizLockedException;
|
|
use Tvdt\Form\BankQuestionFormType;
|
|
use Tvdt\Repository\BankQuestionRepository;
|
|
use Tvdt\Repository\QuestionLabelRepository;
|
|
use Tvdt\Repository\QuizRepository;
|
|
use Tvdt\Security\Voter\SeasonVoter;
|
|
use Tvdt\Service\QuestionBankService;
|
|
|
|
#[AsController]
|
|
#[IsGranted('IS_AUTHENTICATED')]
|
|
class QuestionBankController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private readonly TranslatorInterface $translator,
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly BankQuestionRepository $bankQuestionRepository,
|
|
private readonly QuestionLabelRepository $questionLabelRepository,
|
|
private readonly QuizRepository $quizRepository,
|
|
private readonly QuestionBankService $questionBankService,
|
|
private readonly SluggerInterface $slugger,
|
|
) {}
|
|
|
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
|
#[Route(
|
|
'/backoffice/season/{seasonCode:season}/question-bank',
|
|
name: 'tvdt_backoffice_question_bank',
|
|
requirements: ['seasonCode' => 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();
|
|
|
|
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
|
|
|
$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'));
|
|
|
|
if ($isTurboFrame) {
|
|
return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
|
|
}
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
|
}
|
|
|
|
$template = $isTurboFrame
|
|
? 'backoffice/question_bank/_frame.html.twig'
|
|
: 'backoffice/question_bank/form.html.twig';
|
|
|
|
$response = $this->render($template, [
|
|
'season' => $season,
|
|
'form' => $form,
|
|
'bankQuestion' => null,
|
|
]);
|
|
|
|
if ($form->isSubmitted()) {
|
|
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
#[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);
|
|
|
|
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
|
|
|
$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'));
|
|
|
|
if ($isTurboFrame) {
|
|
return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
|
|
}
|
|
|
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
|
}
|
|
|
|
$template = $isTurboFrame
|
|
? 'backoffice/question_bank/_frame.html.twig'
|
|
: 'backoffice/question_bank/form.html.twig';
|
|
|
|
$response = $this->render($template, [
|
|
'season' => $season,
|
|
'form' => $form,
|
|
'bankQuestion' => $bankQuestion,
|
|
]);
|
|
|
|
if ($form->isSubmitted()) {
|
|
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
#[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)],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|