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:
2026-07-04 21:54:44 +02:00
parent f05f88bcc5
commit 8304d8680b
15 changed files with 329 additions and 23 deletions
@@ -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)],
),
);
}
}
}
+12 -3
View File
@@ -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]);
}
}