From 6a77df402dabd1fb51e8cbfc70e125b369e890c6 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Sat, 7 Jun 2025 20:49:21 +0200 Subject: [PATCH] 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. --- assets/backoffice.js | 2 +- assets/controllers/bo/quiz_controller.js | 17 +++++ migrations/Version20250607154730.php | 41 ++++++++++ migrations/Version20250607184525.php | 53 +++++++++++++ rector.php | 1 + src/Controller/AbstractController.php | 1 + .../PrepareEliminationController.php | 3 +- src/Controller/Backoffice/QuizController.php | 35 +++++++++ .../Backoffice/SeasonController.php | 2 +- src/Entity/Elimination.php | 5 +- src/Entity/GivenAnswer.php | 2 +- src/Entity/Season.php | 1 + src/Exception/ErrorClearingQuizException.php | 7 ++ src/Repository/QuizRepository.php | 44 ++++++++++- templates/backoffice/index.html.twig | 74 ++++++++++--------- templates/backoffice/quiz.html.twig | 62 +++++++++++++--- tests/Security/Voter/SeasonVoterTest.php | 8 +- translations/messages+intl-icu.nl.xliff | 32 +++++++- 18 files changed, 327 insertions(+), 63 deletions(-) create mode 100644 assets/controllers/bo/quiz_controller.js create mode 100644 migrations/Version20250607154730.php create mode 100644 migrations/Version20250607184525.php create mode 100644 src/Exception/ErrorClearingQuizException.php diff --git a/assets/backoffice.js b/assets/backoffice.js index dcb5730..0729569 100644 --- a/assets/backoffice.js +++ b/assets/backoffice.js @@ -1,5 +1,5 @@ +import * as bootstrap from 'bootstrap' import './bootstrap.js'; import 'bootstrap/dist/css/bootstrap.min.css' -import * as bootstrap from 'bootstrap' import './styles/backoffice.scss'; diff --git a/assets/controllers/bo/quiz_controller.js b/assets/controllers/bo/quiz_controller.js new file mode 100644 index 0000000..99f8412 --- /dev/null +++ b/assets/controllers/bo/quiz_controller.js @@ -0,0 +1,17 @@ +import {Controller} from '@hotwired/stimulus'; +import * as bootstrap from 'bootstrap' + +export default class extends Controller { + connect() { + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') + const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)) + } + + clearQuiz() { + new bootstrap.Modal('#clearQuizModal').show(); + } + + deleteQuiz() { + new bootstrap.Modal('#deleteQuizModal').show(); + } +} diff --git a/migrations/Version20250607154730.php b/migrations/Version20250607154730.php new file mode 100644 index 0000000..4c3868d --- /dev/null +++ b/migrations/Version20250607154730.php @@ -0,0 +1,41 @@ +addSql(<<<'SQL' + ALTER TABLE season DROP CONSTRAINT FK_F0E45BA96706D6B + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE season ADD CONSTRAINT FK_F0E45BA96706D6B FOREIGN KEY (active_quiz_id) REFERENCES quiz (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE season DROP CONSTRAINT fk_f0e45ba96706d6b + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE season ADD CONSTRAINT fk_f0e45ba96706d6b FOREIGN KEY (active_quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } +} diff --git a/migrations/Version20250607184525.php b/migrations/Version20250607184525.php new file mode 100644 index 0000000..11d7366 --- /dev/null +++ b/migrations/Version20250607184525.php @@ -0,0 +1,53 @@ +addSql(<<<'SQL' + ALTER TABLE elimination DROP CONSTRAINT FK_5947284F853CD175 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE elimination ADD CONSTRAINT FK_5947284F853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE given_answer DROP CONSTRAINT FK_9AC61A30853CD175 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE given_answer ADD CONSTRAINT FK_9AC61A30853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE given_answer DROP CONSTRAINT fk_9ac61a30853cd175 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE given_answer ADD CONSTRAINT fk_9ac61a30853cd175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE elimination DROP CONSTRAINT fk_5947284f853cd175 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE elimination ADD CONSTRAINT fk_5947284f853cd175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } +} diff --git a/rector.php b/rector.php index 6116cfd..45e73b9 100644 --- a/rector.php +++ b/rector.php @@ -27,6 +27,7 @@ return RectorConfig::configure() phpunitCodeQuality: true, doctrineCodeQuality: true, symfonyCodeQuality: true, + // naming: true ) ->withAttributesSets(all: true) ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true) diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php index 4663bef..417a4b0 100644 --- a/src/Controller/AbstractController.php +++ b/src/Controller/AbstractController.php @@ -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] diff --git a/src/Controller/Backoffice/PrepareEliminationController.php b/src/Controller/Backoffice/PrepareEliminationController.php index 0ed5fe3..d88fcd7 100644 --- a/src/Controller/Backoffice/PrepareEliminationController.php +++ b/src/Controller/Backoffice/PrepareEliminationController.php @@ -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'); } diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index d9db54f..dd49a59 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -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', diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php index c280225..72decd1 100644 --- a/src/Controller/Backoffice/SeasonController.php +++ b/src/Controller/Backoffice/SeasonController.php @@ -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( diff --git a/src/Entity/Elimination.php b/src/Entity/Elimination.php index 6efeab8..a316471 100644 --- a/src/Entity/Elimination.php +++ b/src/Entity/Elimination.php @@ -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 $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)); diff --git a/src/Entity/GivenAnswer.php b/src/Entity/GivenAnswer.php index 6d83857..b7c0fc3 100644 --- a/src/Entity/GivenAnswer.php +++ b/src/Entity/GivenAnswer.php @@ -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')] diff --git a/src/Entity/Season.php b/src/Entity/Season.php index 42ad344..4d866a4 100644 --- a/src/Entity/Season.php +++ b/src/Entity/Season.php @@ -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() diff --git a/src/Exception/ErrorClearingQuizException.php b/src/Exception/ErrorClearingQuizException.php new file mode 100644 index 0000000..67521ff --- /dev/null +++ b/src/Exception/ErrorClearingQuizException.php @@ -0,0 +1,7 @@ + */ 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(); + } } diff --git a/templates/backoffice/index.html.twig b/templates/backoffice/index.html.twig index 2857cd0..caa956d 100644 --- a/templates/backoffice/index.html.twig +++ b/templates/backoffice/index.html.twig @@ -11,43 +11,45 @@ {{ 'Add'|trans }} - - - - {% if is_granted('ROLE_ADMIN') %} - - {% endif %} - - - - - - - - {% for season in seasons %} - + {% if seasons %} +
{{ 'Owner(s)'|trans }}{{ 'Name'|trans }}{{ 'Active Quiz'|trans }}{{ 'Season Code'|trans }}{{ 'Manage'|trans }}
+ + {% if is_granted('ROLE_ADMIN') %} - + {% endif %} - - - - + + + + - {% else %} - EMPTY - {% endfor %} - -
{{ season.owners|map(o => o.email)|join(', ') }}{{ 'Owner(s)'|trans }}{{ season.name }} - {% if season.activeQuiz %} - {{ season.activeQuiz.name }} - {% else %} - {{ 'No active quiz'|trans }} - {% endif %} - - {{ season.seasonCode }} - - {{ 'Manage'|trans }} - {{ 'Name'|trans }}{{ 'Active Quiz'|trans }}{{ 'Season Code'|trans }}{{ 'Manage'|trans }}
+ + + {% for season in seasons %} + + {% if is_granted('ROLE_ADMIN') %} + {{ season.owners|map(o => o.email)|join(', ') }} + {% endif %} + {{ season.name }} + + {% if season.activeQuiz %} + {{ season.activeQuiz.name }} + {% else %} + {{ 'No active quiz'|trans }} + {% endif %} + + + {{ season.seasonCode }} + + + {{ 'Manage'|trans }} + + + {% endfor %} + + + {% else %} + {{ 'You have no seasons yet.'|trans }} + {% endif %} {% endblock %} diff --git a/templates/backoffice/quiz.html.twig b/templates/backoffice/quiz.html.twig index 34b8629..df906b8 100644 --- a/templates/backoffice/quiz.html.twig +++ b/templates/backoffice/quiz.html.twig @@ -2,13 +2,19 @@ {% block body %}

{{ 'Quiz'|trans }}: {{ quiz.season.name }} - {{ quiz.name }}

-
+
{{ 'Make active'|trans }} {% if quiz is same as (season.activeQuiz) %} {{ 'Deactivate Quiz'|trans }} {% endif %} + +
@@ -111,16 +117,48 @@
-{% endblock %} -{% block javascripts %} - {{ parent() }} - -{% endblock javascripts %} -{% block title %} + {# Modal Clear #} + + + {# Modal Delete #} + +{% endblock %} +{% block title %} {% endblock %} diff --git a/tests/Security/Voter/SeasonVoterTest.php b/tests/Security/Voter/SeasonVoterTest.php index 31afe01..289b221 100644 --- a/tests/Security/Voter/SeasonVoterTest.php +++ b/tests/Security/Voter/SeasonVoterTest.php @@ -18,19 +18,19 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; -class SeasonVoterTest extends TestCase +final class SeasonVoterTest extends TestCase { private SeasonVoter $seasonVoter; + private TokenInterface&Stub $token; - private User&Stub $user; protected function setUp(): void { $this->seasonVoter = new SeasonVoter(); $this->token = $this->createStub(TokenInterface::class); - $this->user = $this->createStub(User::class); - $this->token->method('getUser')->willReturn($this->user); + $user = $this->createStub(User::class); + $this->token->method('getUser')->willReturn($user); } #[DataProvider('typesProvider')] diff --git a/translations/messages+intl-icu.nl.xliff b/translations/messages+intl-icu.nl.xliff index 3c595ce..74101b4 100644 --- a/translations/messages+intl-icu.nl.xliff +++ b/translations/messages+intl-icu.nl.xliff @@ -49,6 +49,10 @@ Candidates Kandidaten + + Clear quiz... + Test leegmaken... + Correct Answers Goede antwoorden @@ -77,6 +81,10 @@ Deactivate Quiz Deactiveer test + + Delete Quiz... + Test verwijderen... + Download Template Download sjabloon @@ -93,6 +101,10 @@ Enter your name Voor je naam in + + Error clearing quiz + Fout bij leegmaken test + Green Groen @@ -177,10 +189,18 @@ Quiz Added! Test toegevoegd! + + Quiz cleared + Test leeggemaakt + Quiz completed Test voltooid + + Quiz deleted + Test verwijderd + Quiz name Testnaam @@ -237,10 +257,6 @@ Sign in Log in - - Start Elimination - Start eliminatie - Submit Verstuur @@ -253,10 +269,18 @@ There are no answers for this question Er zijn geen antwoorden voor deze vraag + + There is no active quiz + Er is geen test actief + Time Tijd + + You have no seasons yet. + Je hebt nog geen seizoenen. + Your Seasons Jouw seizoenen