From beb8d13dde98b2e6c3c71ed3d372044559997fd4 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Fri, 6 Jun 2025 23:03:13 +0200 Subject: [PATCH] Refactor Candidate and Quiz entities, rename Correction to QuizCandidate, and update related workflows This commit removes nullable Uuid properties for consistency, transitions the Correction entity to QuizCandidate with associated migrations, refactors queries and repositories, adjusts related routes and controllers to use the new entity, updates front-end assets for elimination workflows, and standardizes route requirements and naming conventions. --- assets/controllers/elimination_controller.js | 10 +++ assets/quiz.js | 19 ---- migrations/Version20250606192337.php | 90 +++++++++++++++++++ migrations/Version20250606195952.php | 42 +++++++++ src/Controller/AbstractController.php | 3 + .../Admin/CorrectionCrudController.php | 4 +- src/Controller/Admin/DashboardController.php | 4 +- .../PrepareEliminationController.php | 14 ++- src/Controller/Backoffice/QuizController.php | 8 +- .../Backoffice/SeasonController.php | 20 ++++- src/Controller/QuizController.php | 43 +++++---- src/Entity/Answer.php | 10 --- src/Entity/Candidate.php | 38 ++------ src/Entity/Correction.php | 74 --------------- src/Entity/Elimination.php | 2 +- src/Entity/GivenAnswer.php | 55 ++++-------- src/Entity/Question.php | 4 +- src/Entity/Quiz.php | 28 ++---- src/Entity/QuizCandidate.php | 79 ++++++++++++++++ src/Entity/Season.php | 4 +- src/Entity/User.php | 4 +- src/Repository/CandidateRepository.php | 20 +++-- src/Repository/CorrectionRepository.php | 20 ----- src/Repository/EliminationRepository.php | 25 ------ src/Repository/QuizCandidateRepository.php | 36 ++++++++ templates/backoffice/index.html.twig | 2 +- .../quiz/elimination/candidate.html.twig | 9 +- 27 files changed, 385 insertions(+), 282 deletions(-) create mode 100644 assets/controllers/elimination_controller.js create mode 100644 migrations/Version20250606192337.php create mode 100644 migrations/Version20250606195952.php delete mode 100644 src/Entity/Correction.php create mode 100644 src/Entity/QuizCandidate.php delete mode 100644 src/Repository/CorrectionRepository.php create mode 100644 src/Repository/QuizCandidateRepository.php diff --git a/assets/controllers/elimination_controller.js b/assets/controllers/elimination_controller.js new file mode 100644 index 0000000..6550bee --- /dev/null +++ b/assets/controllers/elimination_controller.js @@ -0,0 +1,10 @@ +import {Controller} from '@hotwired/stimulus'; + +export default class extends Controller { + next() { + const currentUrl = window.location.href; + const urlParts = currentUrl.split('/'); + urlParts.pop(); + window.location.href = urlParts.join('/'); + } +} diff --git a/assets/quiz.js b/assets/quiz.js index f7c2c54..b9160a7 100644 --- a/assets/quiz.js +++ b/assets/quiz.js @@ -4,22 +4,3 @@ import * as bootstrap from 'bootstrap' import './styles/app.scss' -document.addEventListener('DOMContentLoaded', function () { - // Check if we're on the elimination candidate screen - const eliminationScreen = document.querySelector('.elimination-screen'); - if (eliminationScreen) { - // Add event listener for any keypress - document.addEventListener('keydown', function (event) { - // Get the current URL - const currentUrl = window.location.href; - // Extract the elimination ID from the URL - const urlParts = currentUrl.split('/'); - // Remove the candidate hash (last part of the URL) - urlParts.pop(); - // Construct the URL to the main elimination page - const redirectUrl = urlParts.join('/'); - // Redirect to the main elimination page - window.location.href = redirectUrl; - }); - } -}); diff --git a/migrations/Version20250606192337.php b/migrations/Version20250606192337.php new file mode 100644 index 0000000..0ce244b --- /dev/null +++ b/migrations/Version20250606192337.php @@ -0,0 +1,90 @@ +addSql(<<<'SQL' + CREATE TABLE quiz_candidate (id UUID NOT NULL, corrections DOUBLE PRECISION NOT NULL, created TIMESTAMP(0) WITH TIME ZONE NOT NULL, quiz_id UUID NOT NULL, candidate_id UUID NOT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_CED2FFA2853CD175 ON quiz_candidate (quiz_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_CED2FFA291BD8781 ON quiz_candidate (candidate_id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_CED2FFA291BD8781853CD175 ON quiz_candidate (candidate_id, quiz_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE quiz_candidate ADD CONSTRAINT FK_CED2FFA2853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE quiz_candidate ADD CONSTRAINT FK_CED2FFA291BD8781 FOREIGN KEY (candidate_id) REFERENCES candidate (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE correction DROP CONSTRAINT fk_a29da1b891bd8781 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE correction DROP CONSTRAINT fk_a29da1b8853cd175 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE correction + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE elimination ALTER created TYPE TIMESTAMP(0) WITH TIME ZONE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE given_answer ALTER created TYPE TIMESTAMP(0) WITH TIME ZONE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE correction (id UUID NOT NULL, candidate_id UUID NOT NULL, quiz_id UUID NOT NULL, amount DOUBLE PRECISION NOT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uniq_a29da1b891bd8781853cd175 ON correction (candidate_id, quiz_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX idx_a29da1b8853cd175 ON correction (quiz_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX idx_a29da1b891bd8781 ON correction (candidate_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE correction ADD CONSTRAINT fk_a29da1b891bd8781 FOREIGN KEY (candidate_id) REFERENCES candidate (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE correction ADD CONSTRAINT fk_a29da1b8853cd175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE quiz_candidate DROP CONSTRAINT FK_CED2FFA2853CD175 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE quiz_candidate DROP CONSTRAINT FK_CED2FFA291BD8781 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE quiz_candidate + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE given_answer ALTER created TYPE TIMESTAMP(0) WITHOUT TIME ZONE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE elimination ALTER created TYPE TIMESTAMP(0) WITHOUT TIME ZONE + SQL); + } +} diff --git a/migrations/Version20250606195952.php b/migrations/Version20250606195952.php new file mode 100644 index 0000000..f0aa594 --- /dev/null +++ b/migrations/Version20250606195952.php @@ -0,0 +1,42 @@ +addSql(<<<'SQL' + delete from given_answer where answer_id is null + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE given_answer ALTER answer_id TYPE UUID + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE given_answer ALTER answer_id SET NOT NULL + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE given_answer ALTER answer_id TYPE UUID + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE given_answer ALTER answer_id DROP NOT NULL + SQL); + } +} diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php index 347eccf..4663bef 100644 --- a/src/Controller/AbstractController.php +++ b/src/Controller/AbstractController.php @@ -9,6 +9,9 @@ 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] protected function addFlash(FlashType|string $type, mixed $message): void { diff --git a/src/Controller/Admin/CorrectionCrudController.php b/src/Controller/Admin/CorrectionCrudController.php index 89b649f..6cc2028 100644 --- a/src/Controller/Admin/CorrectionCrudController.php +++ b/src/Controller/Admin/CorrectionCrudController.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace App\Controller\Admin; -use App\Entity\Correction; +use App\Entity\QuizCandidate; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; class CorrectionCrudController extends AbstractCrudController { public static function getEntityFqcn(): string { - return Correction::class; + return QuizCandidate::class; } } diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 0933738..97bb080 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -6,10 +6,10 @@ namespace App\Controller\Admin; use App\Entity\Answer; use App\Entity\Candidate; -use App\Entity\Correction; use App\Entity\GivenAnswer; use App\Entity\Question; use App\Entity\Quiz; +use App\Entity\QuizCandidate; use App\Entity\Season; use App\Entity\User; use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard; @@ -58,7 +58,7 @@ class DashboardController extends AbstractDashboardController yield MenuItem::linkToCrud('Quiz', 'fas fa-list', Quiz::class); yield MenuItem::linkToCrud('Question', 'fas fa-list', Question::class); yield MenuItem::linkToCrud('Candidate', 'fas fa-list', Candidate::class); - yield MenuItem::linkToCrud('Correction', 'fas fa-list', Correction::class); + yield MenuItem::linkToCrud('Correction', 'fas fa-list', QuizCandidate::class); yield MenuItem::linkToCrud('User', 'fas fa-list', User::class); yield MenuItem::linkToCrud('Given Answer', 'fas fa-list', GivenAnswer::class); yield MenuItem::linkToCrud('Answer', 'fas fa-list', Answer::class); diff --git a/src/Controller/Backoffice/PrepareEliminationController.php b/src/Controller/Backoffice/PrepareEliminationController.php index 96ee405..0ed5fe3 100644 --- a/src/Controller/Backoffice/PrepareEliminationController.php +++ b/src/Controller/Backoffice/PrepareEliminationController.php @@ -4,19 +4,23 @@ declare(strict_types=1); namespace App\Controller\Backoffice; +use App\Controller\AbstractController; use App\Entity\Elimination; use App\Entity\Quiz; use App\Entity\Season; use App\Factory\EliminationFactory; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; final class PrepareEliminationController extends AbstractController { - #[Route('/backoffice/elimination/{seasonCode}/{quiz}/prepare', name: 'app_prepare_elimination')] + #[Route( + '/backoffice/elimination/{seasonCode}/{quiz}/prepare', + name: 'app_prepare_elimination', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + )] public function index(Season $season, Quiz $quiz, EliminationFactory $eliminationFactory): Response { $elimination = $eliminationFactory->createEliminationFromQuiz($quiz); @@ -24,7 +28,11 @@ final class PrepareEliminationController extends AbstractController return $this->redirectToRoute('app_prepare_elimination_view', ['elimination' => $elimination->getId()]); } - #[Route('/backoffice/elimination/{elimination}', name: 'app_prepare_elimination_view')] + #[Route( + '/backoffice/elimination/{elimination}', + name: 'app_prepare_elimination_view', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + )] public function viewElimination(Elimination $elimination, Request $request, EntityManagerInterface $em): Response { if ('POST' === $request->getMethod()) { diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index 047ac6c..a08ce25 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -23,7 +23,9 @@ class QuizController extends AbstractController private readonly CandidateRepository $candidateRepository, ) {} - #[Route('/backoffice/season/{seasonCode}/quiz/{quiz}', name: 'app_backoffice_quiz')] + #[Route('/backoffice/season/{seasonCode}/quiz/{quiz}', name: 'app_backoffice_quiz', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + )] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] public function index(Season $season, Quiz $quiz): Response { @@ -34,7 +36,9 @@ class QuizController extends AbstractController ]); } - #[Route('/backoffice/season/{seasonCode}/quiz/{quiz}/enable', name: 'app_backoffice_enable')] + #[Route('/backoffice/season/{seasonCode}/quiz/{quiz}/enable', name: 'app_backoffice_enable', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + )] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): Response { diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php index 055f811..c280225 100644 --- a/src/Controller/Backoffice/SeasonController.php +++ b/src/Controller/Backoffice/SeasonController.php @@ -29,7 +29,11 @@ class SeasonController extends AbstractController public function __construct(private readonly TranslatorInterface $translator, private EntityManagerInterface $em, ) {} - #[Route('/backoffice/season/{seasonCode}', name: 'app_backoffice_season')] + #[Route( + '/backoffice/season/{seasonCode}', + name: 'app_backoffice_season', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + )] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] public function index(Season $season): Response { @@ -38,7 +42,12 @@ class SeasonController extends AbstractController ]); } - #[Route('/backoffice/season/{seasonCode}/add_candidate', name: 'app_backoffice_add_candidates', priority: 10)] + #[Route( + '/backoffice/season/{seasonCode}/add_candidate', + name: 'app_backoffice_add_candidates', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + priority: 10, + )] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] public function addCandidates(Season $season, Request $request): Response { @@ -59,7 +68,12 @@ class SeasonController extends AbstractController return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]); } - #[Route('/backoffice/season/{seasonCode}/add', name: 'app_backoffice_quiz_add', priority: 10)] + #[Route( + '/backoffice/season/{seasonCode}/add', + name: 'app_backoffice_quiz_add', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX], + priority: 10, + )] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] public function addQuiz(Request $request, Season $season, QuizSpreadsheetService $quizSpreadsheet): Response { diff --git a/src/Controller/QuizController.php b/src/Controller/QuizController.php index 12dd24f..cd7460b 100644 --- a/src/Controller/QuizController.php +++ b/src/Controller/QuizController.php @@ -8,6 +8,7 @@ use App\Entity\Answer; use App\Entity\Candidate; use App\Entity\GivenAnswer; use App\Entity\Question; +use App\Entity\Quiz; use App\Entity\Season; use App\Enum\FlashType; use App\Form\EnterNameType; @@ -17,6 +18,7 @@ use App\Repository\AnswerRepository; use App\Repository\CandidateRepository; use App\Repository\GivenAnswerRepository; use App\Repository\QuestionRepository; +use App\Repository\QuizCandidateRepository; use App\Repository\SeasonRepository; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\Exception\BadRequestException; @@ -29,13 +31,9 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsController] final class QuizController extends AbstractController { - public const string SEASON_CODE_REGEX = '[A-Za-z\d]{5}'; - - private const string CANDIDATE_HASH_REGEX = '[\w\-=]+'; - public function __construct(private readonly TranslatorInterface $translator) {} - #[Route(path: '/', name: 'app_quiz_selectseason', methods: ['GET', 'POST'])] + #[Route(path: '/', name: 'app_quiz_select_season', methods: ['GET', 'POST'])] public function selectSeason(Request $request, SeasonRepository $seasonRepository): Response { $form = $this->createForm(SelectSeasonType::class); @@ -47,16 +45,16 @@ final class QuizController extends AbstractController if ([] === $seasonRepository->findBy(['seasonCode' => $seasonCode])) { $this->addFlash(FlashType::Warning, $this->translator->trans('Invalid season code')); - return $this->redirectToRoute('app_quiz_selectseason'); + return $this->redirectToRoute('app_quiz_select_season'); } - return $this->redirectToRoute('app_quiz_entername', ['seasonCode' => $seasonCode]); + return $this->redirectToRoute('app_quiz_enter_name', ['seasonCode' => $seasonCode]); } return $this->render('quiz/select_season.html.twig', ['form' => $form]); } - #[Route(path: '/{seasonCode}', name: 'app_quiz_entername', requirements: ['seasonCode' => self::SEASON_CODE_REGEX])] + #[Route(path: '/{seasonCode}', name: 'app_quiz_enter_name', requirements: ['seasonCode' => self::SEASON_CODE_REGEX])] public function enterName( Request $request, #[MapEntity(mapping: ['seasonCode' => 'seasonCode'])] @@ -69,7 +67,7 @@ final class QuizController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $name = $form->get('name')->getData(); - return $this->redirectToRoute('app_quiz_quizpage', ['seasonCode' => $season->getSeasonCode(), 'nameHash' => Base64::base64UrlEncode($name)]); + return $this->redirectToRoute('app_quiz_quiz_page', ['seasonCode' => $season->getSeasonCode(), 'nameHash' => Base64::base64UrlEncode($name)]); } return $this->render('quiz/enter_name.twig', ['season' => $season, 'form' => $form]); @@ -77,25 +75,34 @@ final class QuizController extends AbstractController #[Route( path: '/{seasonCode}/{nameHash}', - name: 'app_quiz_quizpage', + name: 'app_quiz_quiz_page', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'nameHash' => self::CANDIDATE_HASH_REGEX], )] public function quizPage( #[MapEntity(mapping: ['seasonCode' => 'seasonCode'])] Season $season, string $nameHash, + Request $request, CandidateRepository $candidateRepository, QuestionRepository $questionRepository, AnswerRepository $answerRepository, GivenAnswerRepository $givenAnswerRepository, - Request $request, + QuizCandidateRepository $quizCandidateRepository, ): Response { $candidate = $candidateRepository->getCandidateByHash($season, $nameHash); if (!$candidate instanceof Candidate) { $this->addFlash(FlashType::Danger, $this->translator->trans('Candidate not found')); - return $this->redirectToRoute('app_quiz_entername', ['seasonCode' => $season->getSeasonCode()]); + return $this->redirectToRoute('app_quiz_enter_name', ['seasonCode' => $season->getSeasonCode()]); + } + + $quiz = $season->getActiveQuiz(); + + if (!$quiz instanceof Quiz) { + $this->addFlash(FlashType::Warning, $this->translator->trans('There is no active quiz')); + + return $this->redirectToRoute('app_quiz_enter_name', ['seasonCode' => $season->getSeasonCode()]); } if ('POST' === $request->getMethod()) { @@ -105,13 +112,10 @@ final class QuizController extends AbstractController throw new BadRequestException('Invalid Answer ID'); } - $givenAnswer = (new GivenAnswer()) - ->setCandidate($candidate) - ->setAnswer($answer) - ->setQuiz($answer->getQuestion()->getQuiz()); + $givenAnswer = new GivenAnswer($candidate, $answer->getQuestion()->getQuiz(), $answer); $givenAnswerRepository->save($givenAnswer); - return $this->redirectToRoute('app_quiz_quizpage', ['seasonCode' => $season->getSeasonCode(), 'nameHash' => $nameHash]); + return $this->redirectToRoute('app_quiz_quiz_page', ['seasonCode' => $season->getSeasonCode(), 'nameHash' => $nameHash]); } $question = $questionRepository->findNextQuestionForCandidate($candidate); @@ -119,10 +123,11 @@ final class QuizController extends AbstractController if (!$question instanceof Question) { $this->addFlash(FlashType::Success, $this->translator->trans('Quiz completed')); - return $this->redirectToRoute('app_quiz_entername', ['seasonCode' => $season->getSeasonCode()]); + return $this->redirectToRoute('app_quiz_enter_name', ['seasonCode' => $season->getSeasonCode()]); } - // TODO One first question record time + $quizCandidateRepository->createIfNotExist($quiz, $candidate); + return $this->render('quiz/question.twig', ['candidate' => $candidate, 'question' => $question]); } } diff --git a/src/Entity/Answer.php b/src/Entity/Answer.php index ffb217b..2d96e15 100644 --- a/src/Entity/Answer.php +++ b/src/Entity/Answer.php @@ -116,16 +116,6 @@ class Answer return $this->givenAnswers; } - public function addGivenAnswer(GivenAnswer $givenAnswer): static - { - if (!$this->givenAnswers->contains($givenAnswer)) { - $this->givenAnswers->add($givenAnswer); - $givenAnswer->setAnswer($this); - } - - return $this; - } - public function getOrdering(): int { return $this->ordering; diff --git a/src/Entity/Candidate.php b/src/Entity/Candidate.php index ee6254f..ddd35fb 100644 --- a/src/Entity/Candidate.php +++ b/src/Entity/Candidate.php @@ -21,7 +21,7 @@ class Candidate #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: UuidGenerator::class)] - private ?Uuid $id = null; + private Uuid $id; #[ORM\ManyToOne(inversedBy: 'candidates')] #[ORM\JoinColumn(nullable: false)] @@ -35,9 +35,9 @@ class Candidate #[ORM\OneToMany(targetEntity: GivenAnswer::class, mappedBy: 'candidate', orphanRemoval: true)] private Collection $givenAnswers; - /** @var Collection */ - #[ORM\OneToMany(targetEntity: Correction::class, mappedBy: 'candidate', orphanRemoval: true)] - private Collection $corrections; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: QuizCandidate::class, mappedBy: 'candidate', orphanRemoval: true)] + private Collection $quizData; public function __construct( #[ORM\Column(length: 16)] @@ -45,10 +45,10 @@ class Candidate ) { $this->answersOnCandidate = new ArrayCollection(); $this->givenAnswers = new ArrayCollection(); - $this->corrections = new ArrayCollection(); + $this->quizData = new ArrayCollection(); } - public function getId(): ?Uuid + public function getId(): Uuid { return $this->id; } @@ -108,30 +108,10 @@ class Candidate return $this->givenAnswers; } - public function addGivenAnswer(GivenAnswer $givenAnswer): static + /** @return Collection */ + public function getQuizData(): Collection { - if (!$this->givenAnswers->contains($givenAnswer)) { - $this->givenAnswers->add($givenAnswer); - $givenAnswer->setCandidate($this); - } - - return $this; - } - - /** @return Collection */ - public function getCorrections(): Collection - { - return $this->corrections; - } - - public function addCorrection(Correction $correction): static - { - if (!$this->corrections->contains($correction)) { - $this->corrections->add($correction); - $correction->setCandidate($this); - } - - return $this; + return $this->quizData; } public function getNameHash(): string diff --git a/src/Entity/Correction.php b/src/Entity/Correction.php deleted file mode 100644 index 5e9cd26..0000000 --- a/src/Entity/Correction.php +++ /dev/null @@ -1,74 +0,0 @@ -id; - } - - public function getCandidate(): Candidate - { - return $this->candidate; - } - - public function setCandidate(Candidate $candidate): static - { - $this->candidate = $candidate; - - return $this; - } - - public function getQuiz(): Quiz - { - return $this->quiz; - } - - public function setQuiz(Quiz $quiz): static - { - $this->quiz = $quiz; - - return $this; - } - - public function getAmount(): ?float - { - return $this->amount; - } - - public function setAmount(float $amount): static - { - $this->amount = $amount; - - return $this; - } -} diff --git a/src/Entity/Elimination.php b/src/Entity/Elimination.php index beaae93..6efeab8 100644 --- a/src/Entity/Elimination.php +++ b/src/Entity/Elimination.php @@ -30,7 +30,7 @@ class Elimination #[ORM\Column(type: Types::JSON)] private array $data = []; - #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: false)] + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)] private \DateTimeImmutable $created; public function __construct( diff --git a/src/Entity/GivenAnswer.php b/src/Entity/GivenAnswer.php index 851d311..6d83857 100644 --- a/src/Entity/GivenAnswer.php +++ b/src/Entity/GivenAnswer.php @@ -22,22 +22,24 @@ class GivenAnswer #[ORM\CustomIdGenerator(class: UuidGenerator::class)] private Uuid $id; - #[ORM\ManyToOne(inversedBy: 'givenAnswers')] - #[ORM\JoinColumn(nullable: false)] - private Candidate $candidate; - - #[ORM\ManyToOne] - #[ORM\JoinColumn(nullable: false)] - private Quiz $quiz; - - #[ORM\ManyToOne(inversedBy: 'givenAnswers')] - #[ORM\JoinColumn(nullable: true)] - private ?Answer $answer = null; - - #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: false)] + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)] private \DateTimeImmutable $created; - public function getId(): ?Uuid + public function __construct( + #[ORM\ManyToOne(inversedBy: 'givenAnswers')] + #[ORM\JoinColumn(nullable: false)] + private Candidate $candidate, + + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: false)] + private Quiz $quiz, + + #[ORM\ManyToOne(inversedBy: 'givenAnswers')] + #[ORM\JoinColumn(nullable: false)] + private Answer $answer, + ) {} + + public function getId(): Uuid { return $this->id; } @@ -47,37 +49,16 @@ class GivenAnswer return $this->candidate; } - public function setCandidate(Candidate $candidate): static - { - $this->candidate = $candidate; - - return $this; - } - - public function getQuiz(): ?Quiz + public function getQuiz(): Quiz { return $this->quiz; } - public function setQuiz(Quiz $quiz): static - { - $this->quiz = $quiz; - - return $this; - } - - public function getAnswer(): ?Answer + public function getAnswer(): Answer { return $this->answer; } - public function setAnswer(?Answer $answer): static - { - $this->answer = $answer; - - return $this; - } - public function getCreated(): \DateTimeImmutable { return $this->created; diff --git a/src/Entity/Question.php b/src/Entity/Question.php index 2c618f4..faeb83f 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -20,7 +20,7 @@ class Question #[ORM\Column(type: UuidType::NAME)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: UuidGenerator::class)] - private ?Uuid $id = null; + private Uuid $id; #[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])] private int $ordering; @@ -45,7 +45,7 @@ class Question $this->answers = new ArrayCollection(); } - public function getId(): ?Uuid + public function getId(): Uuid { return $this->id; } diff --git a/src/Entity/Quiz.php b/src/Entity/Quiz.php index 1f3981f..958876d 100644 --- a/src/Entity/Quiz.php +++ b/src/Entity/Quiz.php @@ -20,7 +20,7 @@ class Quiz #[ORM\Column(type: UuidType::NAME)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: UuidGenerator::class)] - private ?Uuid $id = null; + private Uuid $id; #[ORM\Column(length: 64)] private string $name; @@ -34,9 +34,9 @@ class Quiz #[ORM\OrderBy(['ordering' => 'ASC'])] private Collection $questions; - /** @var Collection */ - #[ORM\OneToMany(targetEntity: Correction::class, mappedBy: 'quiz', orphanRemoval: true)] - private Collection $corrections; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: QuizCandidate::class, mappedBy: 'quiz', orphanRemoval: true)] + private Collection $candidateData; #[ORM\Column(nullable: false, options: ['default' => 1])] private int $dropouts = 1; @@ -49,11 +49,11 @@ class Quiz public function __construct() { $this->questions = new ArrayCollection(); - $this->corrections = new ArrayCollection(); + $this->candidateData = new ArrayCollection(); $this->eliminations = new ArrayCollection(); } - public function getId(): ?Uuid + public function getId(): Uuid { return $this->id; } @@ -98,20 +98,10 @@ class Quiz return $this; } - /** @return Collection */ - public function getCorrections(): Collection + /** @return Collection */ + public function getCandidateData(): Collection { - return $this->corrections; - } - - public function addCorrection(Correction $correction): static - { - if (!$this->corrections->contains($correction)) { - $this->corrections->add($correction); - $correction->setQuiz($this); - } - - return $this; + return $this->candidateData; } public function getDropouts(): int diff --git a/src/Entity/QuizCandidate.php b/src/Entity/QuizCandidate.php new file mode 100644 index 0000000..ff1c41a --- /dev/null +++ b/src/Entity/QuizCandidate.php @@ -0,0 +1,79 @@ +id; + } + + public function getCandidate(): Candidate + { + return $this->candidate; + } + + public function getQuiz(): Quiz + { + return $this->quiz; + } + + public function getCorrections(): ?float + { + return $this->corrections; + } + + public function setCorrections(float $corrections): static + { + $this->corrections = $corrections; + + return $this; + } + + public function getCreated(): \DateTimeImmutable + { + return $this->created; + } + + #[ORM\PrePersist] + public function setCreatedAtValue(): void + { + $this->created = new DateTimeImmutable(); + } +} diff --git a/src/Entity/Season.php b/src/Entity/Season.php index 97d65b9..42ad344 100644 --- a/src/Entity/Season.php +++ b/src/Entity/Season.php @@ -21,7 +21,7 @@ class Season #[ORM\Column(type: UuidType::NAME)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: UuidGenerator::class)] - private ?Uuid $id = null; + private Uuid $id; #[ORM\Column(length: 64)] private string $name; @@ -52,7 +52,7 @@ class Season $this->owners = new ArrayCollection(); } - public function getId(): ?Uuid + public function getId(): Uuid { return $this->id; } diff --git a/src/Entity/User.php b/src/Entity/User.php index 5ca9d4b..2c20f09 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -26,7 +26,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\CustomIdGenerator(class: UuidGenerator::class)] - private ?Uuid $id = null; + private Uuid $id; #[ORM\Column(length: 180)] private string $email; @@ -51,7 +51,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface $this->seasons = new ArrayCollection(); } - public function getId(): ?Uuid + public function getId(): Uuid { return $this->id; } diff --git a/src/Repository/CandidateRepository.php b/src/Repository/CandidateRepository.php index ce61c02..3cdbaa4 100644 --- a/src/Repository/CandidateRepository.php +++ b/src/Repository/CandidateRepository.php @@ -5,12 +5,10 @@ declare(strict_types=1); namespace App\Repository; use App\Entity\Candidate; -use App\Entity\Correction; use App\Entity\Quiz; use App\Entity\Season; use App\Helpers\Base64; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; -use Doctrine\ORM\Query\Expr\Join; use Doctrine\Persistence\ManagerRegistry; use Safe\Exceptions\UrlException; use Symfony\Component\Uid\Uuid; @@ -56,20 +54,26 @@ class CandidateRepository extends ServiceEntityRepository /** @return ResultList */ public function getScores(Quiz $quiz): array { - $scoreTimeQb = $this->createQueryBuilder('c', 'c.id') - ->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'max(ga.created) - min(ga.created) as time') + $scoreQb = $this->createQueryBuilder('c', 'c.id') + ->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct') ->join('c.givenAnswers', 'ga') ->join('ga.answer', 'a') ->where('ga.quiz = :quiz') ->groupBy('c.id') ->setParameter('quiz', $quiz); - $correctionsQb = $this->createQueryBuilder('c', 'c.id') - ->select('c.id', 'cor.amount as corrections') - ->innerJoin(Correction::class, 'cor', Join::WITH, 'cor.candidate = c and cor.quiz = :quiz') + $startTimeCorrectionQb = $this->createQueryBuilder('c', 'c.id') + ->select('c.id', 'qc.corrections', 'max(ga.created) - qc.created as time') + ->join('c.quizData', 'qc') + ->join('c.givenAnswers', 'ga') + ->where('qc.quiz = :quiz') + ->groupBy('ga.quiz', 'c.id', 'qc.id') ->setParameter('quiz', $quiz); - $merged = array_merge_recursive($scoreTimeQb->getQuery()->getArrayResult(), $correctionsQb->getQuery()->getArrayResult()); + $merged = array_merge_recursive( + $scoreQb->getQuery()->getArrayResult(), + $startTimeCorrectionQb->getQuery()->getArrayResult(), + ); return $this->sortResults($this->calculateScore($merged)); } diff --git a/src/Repository/CorrectionRepository.php b/src/Repository/CorrectionRepository.php deleted file mode 100644 index c9ae6e5..0000000 --- a/src/Repository/CorrectionRepository.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -class CorrectionRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, Correction::class); - } -} diff --git a/src/Repository/EliminationRepository.php b/src/Repository/EliminationRepository.php index 60782c2..8e28752 100644 --- a/src/Repository/EliminationRepository.php +++ b/src/Repository/EliminationRepository.php @@ -17,29 +17,4 @@ class EliminationRepository extends ServiceEntityRepository { parent::__construct($registry, Elimination::class); } - - // /** - // * @return Elimination[] Returns an array of Elimination objects - // */ - // public function findByExampleField($value): array - // { - // return $this->createQueryBuilder('e') - // ->andWhere('e.exampleField = :val') - // ->setParameter('val', $value) - // ->orderBy('e.id', 'ASC') - // ->setMaxResults(10) - // ->getQuery() - // ->getResult() - // ; - // } - - // public function findOneBySomeField($value): ?Elimination - // { - // return $this->createQueryBuilder('e') - // ->andWhere('e.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } } diff --git a/src/Repository/QuizCandidateRepository.php b/src/Repository/QuizCandidateRepository.php new file mode 100644 index 0000000..42e217b --- /dev/null +++ b/src/Repository/QuizCandidateRepository.php @@ -0,0 +1,36 @@ + + */ +class QuizCandidateRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, QuizCandidate::class); + } + + /** @return bool true if a new entry was created */ + public function createIfNotExist(Quiz $quiz, Candidate $candidate): bool + { + if (0 !== $this->count(['candidate' => $candidate, 'quiz' => $quiz])) { + return false; + } + + $quizCandidate = new QuizCandidate($quiz, $candidate); + $this->getEntityManager()->persist($quizCandidate); + $this->getEntityManager()->flush(); + + return true; + } +} diff --git a/templates/backoffice/index.html.twig b/templates/backoffice/index.html.twig index fa1c9be..2857cd0 100644 --- a/templates/backoffice/index.html.twig +++ b/templates/backoffice/index.html.twig @@ -38,7 +38,7 @@ {% endif %} - {{ season.seasonCode }} diff --git a/templates/quiz/elimination/candidate.html.twig b/templates/quiz/elimination/candidate.html.twig index 48453f0..bb09028 100644 --- a/templates/quiz/elimination/candidate.html.twig +++ b/templates/quiz/elimination/candidate.html.twig @@ -1,7 +1,12 @@ {% extends 'quiz/base.html.twig' %} {% block body %} - Screen with colour {{ colour }} + Screen with colour {{ colour }} {% endblock %}