fix: correct lock guards, flush batching, and clearQuiz atomicity in question bank

- syncUsagesAfterEdit: use isLocked() instead of !isFinalized() to avoid
  syncing quizzes with started candidates (would have destroyed GivenAnswer records)
- syncToQuiz action: use isLocked() instead of hasStartedCandidates() to block
  syncing into finalized quizzes that have no started candidates
- QuestionBankService::syncToQuiz: remove internal flush(); callers now
  flush once (syncUsagesAfterEdit after the loop, syncToQuiz action after the call)
- QuizRepository::clearQuiz: delete BankQuestionUsage rows and reset
  finalized_at inside the transaction (previously orphaned usages blocked
  bank question reassignment; finalizedAt reset was a separate non-atomic flush)
- QuizController::finalizeQuiz: add flash when quiz is already finalized
- QuestionBankController::delete: block deletion when any usage references a locked quiz
- BankQuestion::__toString: remove dead null-coalescing on non-nullable string
- SeasonController::addBlankQuiz: align form field with UploadQuizFormType
  (add translation_domain: false, use translator for label)
This commit is contained in:
2026-07-05 11:54:29 +02:00
parent 3d6bb88837
commit 829028c807
6 changed files with 38 additions and 10 deletions
@@ -150,6 +150,15 @@ class QuestionBankController extends AbstractController
{
$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();
@@ -299,13 +308,14 @@ class QuestionBankController extends AbstractController
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'));
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]);
@@ -329,14 +339,20 @@ class QuestionBankController extends AbstractController
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
{
$pendingNames = [];
$synced = false;
foreach ($bankQuestion->usages as $usage) {
if (!$usage->quiz->isFinalized()) {
if (!$usage->quiz->isLocked()) {
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
} elseif (!$usage->quiz->hasStartedCandidates()) {
$synced = true;
} else {
$pendingNames[] = $usage->quiz->name;
}
}
if ($synced) {
$this->em->flush();
}
if ([] !== $pendingNames) {
$this->addFlash(
FlashType::Warning,
+2 -2
View File
@@ -297,8 +297,6 @@ class QuizController extends AbstractController
{
try {
$this->quizRepository->clearQuiz($quiz);
$quiz->finalizedAt = null;
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz cleared and no longer finalized'));
} catch (ErrorClearingQuizException) {
$this->addFlash(FlashType::Danger, $this->translator->trans('Error clearing quiz'));
@@ -323,6 +321,8 @@ class QuizController extends AbstractController
$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]);
@@ -161,7 +161,7 @@ class SeasonController extends AbstractController
public function addBlankQuiz(Request $request, Season $season): Response
{
$form = $this->createFormBuilder(new Quiz())
->add('name', TextType::class, ['label' => 'Quiz name'])
->add('name', TextType::class, ['label' => $this->translator->trans('Quiz name'), 'translation_domain' => false])
->add('save', SubmitType::class, ['label' => 'Create'])
->getForm();
+1 -1
View File
@@ -131,6 +131,6 @@ class BankQuestion implements \Stringable
public function __toString(): string
{
return $this->question ?? '';
return $this->question;
}
}
+14
View File
@@ -72,6 +72,20 @@ class QuizRepository extends ServiceEntityRepository
DQL)
->setParameter('quiz', $quiz)
->execute();
$em->createQuery(<<<DQL
delete from Tvdt\Entity\BankQuestionUsage bqu
where bqu.quiz = :quiz
DQL)
->setParameter('quiz', $quiz)
->execute();
$em->createQuery(<<<DQL
update Tvdt\Entity\Quiz q set q.finalizedAt = null
where q = :quiz
DQL)
->setParameter('quiz', $quiz)
->execute();
}
// @codeCoverageIgnoreStart
catch (\Throwable $throwable) {
-2
View File
@@ -97,8 +97,6 @@ final readonly class QuestionBankService
$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. */