Add quiz clearing and deletion functionality with UI enhancements

This commit introduces the ability to clear quiz results and delete quizzes directly from the backoffice. It includes new routes, controllers, modals for user confirmation, and updates to translations. The `QuizRepository` now supports dedicated methods for clearing results and deleting quizzes along with error handling. Related database migrations and front-end adjustments are also included.
This commit is contained in:
2025-06-07 20:49:21 +02:00
parent 79236d84e9
commit 6a77df402d
18 changed files with 327 additions and 63 deletions

View File

@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBase
abstract class AbstractController extends AbstractBaseController
{
protected const string SEASON_CODE_REGEX = '[A-Za-z\d]{5}';
protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+';
#[\Override]

View File

@@ -39,9 +39,10 @@ final class PrepareEliminationController extends AbstractController
$elimination->updateFromInputBag($request->request);
$em->flush();
if (true === $request->request->getBoolean('start')) {
if ($request->request->getBoolean('start')) {
return $this->redirectToRoute('app_elimination', ['elimination' => $elimination->getId()]);
}
$this->addFlash('success', 'Elimination updated');
}

View File

@@ -8,8 +8,10 @@ use App\Controller\AbstractController;
use App\Entity\Candidate;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Exception\ErrorClearingQuizException;
use App\Repository\CandidateRepository;
use App\Repository\QuizCandidateRepository;
use App\Repository\QuizRepository;
use App\Security\Voter\SeasonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -19,6 +21,7 @@ use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
#[IsGranted('ROLE_USER')]
@@ -26,6 +29,7 @@ class QuizController extends AbstractController
{
public function __construct(
private readonly CandidateRepository $candidateRepository,
private readonly TranslatorInterface $translator,
) {}
#[Route(
@@ -61,6 +65,37 @@ class QuizController extends AbstractController
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
#[Route(
'/backoffice/quiz/{quiz}/clear',
name: 'app_backoffice_quiz_clear',
)]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
public function clearQuiz(Quiz $quiz, QuizRepository $quizRepository): RedirectResponse
{
try {
$quizRepository->clearQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz cleared'));
} catch (ErrorClearingQuizException) {
$this->addFlash('error', $this->translator->trans('Error clearing quiz'));
}
return $this->redirectToRoute('app_backoffice_quiz', ['seasonCode' => $quiz->getSeason()->getSeasonCode(), 'quiz' => $quiz->getId()]);
}
#[Route(
'/backoffice/quiz/{quiz}/delete',
name: 'app_backoffice_quiz_delete',
)]
#[IsGranted(SeasonVoter::DELETE, subject: 'quiz')]
public function deleteQuiz(Quiz $quiz, QuizRepository $quizRepository): RedirectResponse
{
$quizRepository->deleteQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz deleted'));
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $quiz->getSeason()->getSeasonCode()]);
}
#[Route(
'/backoffice/quiz/{quiz}/modify_correction/{candidate}',
name: 'app_backoffice_modify_correction',

View File

@@ -26,7 +26,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
#[IsGranted('ROLE_USER')]
class SeasonController extends AbstractController
{
public function __construct(private readonly TranslatorInterface $translator, private EntityManagerInterface $em,
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $em,
) {}
#[Route(

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Uid\Uuid;
class Elimination
{
public const string SCREEN_GREEN = 'green';
public const string SCREEN_RED = 'red';
#[ORM\Id]
@@ -35,7 +36,7 @@ class Elimination
public function __construct(
#[ORM\ManyToOne(inversedBy: 'eliminations')]
#[ORM\JoinColumn(nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Quiz $quiz,
) {}
@@ -66,7 +67,7 @@ class Elimination
/** @param InputBag<bool|float|int|string> $inputBag */
public function updateFromInputBag(InputBag $inputBag): self
{
foreach ($this->data as $name => $screenColour) {
foreach (array_keys($this->data) as $name) {
$newColour = $inputBag->get('colour-'.mb_strtolower($name));
if (\is_string($newColour)) {
$this->data[$name] = $inputBag->get('colour-'.mb_strtolower($name));

View File

@@ -31,7 +31,7 @@ class GivenAnswer
private Candidate $candidate,
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Quiz $quiz,
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]

View File

@@ -43,6 +43,7 @@ class Season
private Collection $owners;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Quiz $ActiveQuiz = null;
public function __construct()

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exception;
class ErrorClearingQuizException extends \Exception {}

View File

@@ -4,17 +4,59 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Elimination;
use App\Entity\GivenAnswer;
use App\Entity\Quiz;
use App\Entity\QuizCandidate;
use App\Exception\ErrorClearingQuizException;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
/**
* @extends ServiceEntityRepository<Quiz>
*/
class QuizRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
{
parent::__construct($registry, Quiz::class);
}
/** @throws ErrorClearingQuizException */
public function clearQuiz(Quiz $quiz): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->createQueryBuilder()
->delete()->from(QuizCandidate::class, 'qc')
->where('qc.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
$em->createQueryBuilder()
->delete()->from(GivenAnswer::class, 'ga')
->where('ga.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
$em->createQueryBuilder()
->delete()->from(Elimination::class, 'e')
->where('e.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
} catch (\Throwable $throwable) {
$this->logger->error($throwable->getMessage());
$em->rollback();
throw new ErrorClearingQuizException(previous: $throwable);
}
$em->commit();
}
public function deleteQuiz(Quiz $quiz): void
{
$this->getEntityManager()->remove($quiz);
$this->getEntityManager()->flush();
}
}