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