diff --git a/migrations/Version20260309215703.php b/migrations/Version20260309215703.php new file mode 100644 index 0000000..be88e3c --- /dev/null +++ b/migrations/Version20260309215703.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE quiz_candidate ADD active BOOLEAN DEFAULT true NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE quiz_candidate DROP active'); + } +} diff --git a/migrations/Version20260309220448.php b/migrations/Version20260309220448.php new file mode 100644 index 0000000..dd91306 --- /dev/null +++ b/migrations/Version20260309220448.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE quiz_candidate ADD started TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); + + // Copy created to started for existing rows (these were created when quiz started) + $this->addSql('UPDATE quiz_candidate SET started = created WHERE started IS NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE quiz_candidate DROP started'); + } +} diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index c1501fe..12f9420 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -15,9 +15,11 @@ use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Contracts\Translation\TranslatorInterface; use Tvdt\Controller\AbstractController; +use Tvdt\Entity\Answer; use Tvdt\Entity\Candidate; use Tvdt\Entity\Question; use Tvdt\Entity\Quiz; +use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\Season; use Tvdt\Exception\ErrorClearingQuizException; use Tvdt\Repository\QuizCandidateRepository; @@ -92,7 +94,7 @@ class QuizController extends AbstractController public function candidates(Season $season, Quiz $quiz): Response { $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); - assert($fetchedQuiz->questions->count() > 0); + \assert($fetchedQuiz->questions->count() > 0); $firstQuestion = $fetchedQuiz->questions->first(); return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [ @@ -102,13 +104,12 @@ class QuizController extends AbstractController ]); } - #[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[Route( '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}', name: 'tvdt_backoffice_quiz_candidates_question', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], - methods: ['GET'] + methods: ['GET'], )] public function candidates_question(Season $season, Quiz $quiz, Question $question): Response { @@ -127,7 +128,7 @@ class QuizController extends AbstractController '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}', name: 'tvdt_backoffice_quiz_candidates_question_save', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], - methods: ['POST'] + methods: ['POST'], )] public function saveCandidateAnswers(Season $season, Quiz $quiz, Question $question, Request $request, EntityManagerInterface $em): RedirectResponse { @@ -143,7 +144,7 @@ class QuizController extends AbstractController $candidate = $em->getRepository(Candidate::class)->find($candidateId); foreach ((array) $answerIds as $answerId) { - $answer = $em->getRepository(\Tvdt\Entity\Answer::class)->find($answerId); + $answer = $em->getRepository(Answer::class)->find($answerId); if ($answer && $candidate) { $answer->addCandidate($candidate); } @@ -249,4 +250,33 @@ class QuizController extends AbstractController return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); } + + #[IsGranted(SeasonVoter::EDIT, subject: 'quiz')] + #[Route( + '/backoffice/quiz/{quiz}/candidate/{candidate}/toggle', + name: 'tvdt_backoffice_toggle_candidate', + requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID], + )] + public function toggleCandidate(Quiz $quiz, Candidate $candidate, EntityManagerInterface $em): RedirectResponse + { + $quizCandidate = $this->quizCandidateRepository->findOneBy([ + 'quiz' => $quiz, + 'candidate' => $candidate, + ]); + + if (!$quizCandidate) { + // Create new QuizCandidate if it doesn't exist (inactive by default when first toggling) + $quizCandidate = new QuizCandidate($quiz, $candidate); + $quizCandidate->active = false; + $em->persist($quizCandidate); + } else { + $quizCandidate->active = !$quizCandidate->active; + } + + $em->flush(); + + $this->addFlash('success', $this->translator->trans('Candidate status updated')); + + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id, '_fragment' => 'candidates']); + } } diff --git a/src/Controller/QuizController.php b/src/Controller/QuizController.php index 07412bf..7189662 100644 --- a/src/Controller/QuizController.php +++ b/src/Controller/QuizController.php @@ -99,6 +99,14 @@ final class QuizController extends AbstractController if ('POST' === $request->getMethod()) { // TODO: Extract saving answer logic to a service + // Check if candidate is inactive for this quiz + $quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]); + if (null !== $quizCandidate && !$quizCandidate->active) { + $this->addFlash(FlashType::Danger, $this->translator->trans('You are not allowed to answer this quiz')); + + return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]); + } + $answer = $this->answerRepository->findOneBy(['id' => $request->request->get('answer')]); if (!$answer instanceof Answer) { @@ -123,7 +131,14 @@ final class QuizController extends AbstractController return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]); } - $this->quizCandidateRepository->createIfNotExist($quiz, $candidate); + $result = $this->quizCandidateRepository->createIfNotExist($quiz, $candidate); + + // Check if candidate is inactive + if (null === $result) { + $this->addFlash(FlashType::Danger, $this->translator->trans('You are not allowed to answer this quiz')); + + return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]); + } // end of extracting getting next question logic return $this->render('quiz/question.twig', ['candidate' => $candidate, 'question' => $question, 'season' => $season]); diff --git a/src/Entity/Answer.php b/src/Entity/Answer.php index 3f9bb64..8b3cc7b 100644 --- a/src/Entity/Answer.php +++ b/src/Entity/Answer.php @@ -62,5 +62,4 @@ class Answer { return $this->text; } - } diff --git a/src/Entity/Quiz.php b/src/Entity/Quiz.php index 303cec4..f451bc5 100644 --- a/src/Entity/Quiz.php +++ b/src/Entity/Quiz.php @@ -72,6 +72,7 @@ class Quiz /** * Get errors for all questions in the quiz. * Returns an array where keys are question IDs and values are error messages. + * * @return array */ public function getQuestionErrors(): array @@ -91,7 +92,7 @@ class Quiz foreach ($this->questions as $question) { $error = $this->getQuestionError($question, $hasCandidateRelations); - if ($error !== null) { + if (null !== $error) { $errors[$question->id->toString()] = $error; } } @@ -117,7 +118,14 @@ class Quiz // Only validate candidate-answer relations if at least one exists in the quiz if ($hasCandidateRelations) { - $seasonCandidates = $this->season->candidates; + // Get only active candidates for this quiz + $activeCandidates = []; + foreach ($this->candidateData as $quizCandidate) { + if ($quizCandidate->active) { + $activeCandidates[] = $quizCandidate->candidate; + } + } + $candidateCounts = []; // Count how many times each candidate appears in answers @@ -127,19 +135,19 @@ class Quiz if (!isset($candidateCounts[$candidateId])) { $candidateCounts[$candidateId] = ['name' => $candidate->name, 'count' => 0]; } - $candidateCounts[$candidateId]['count']++; + ++$candidateCounts[$candidateId]['count']; } } - // Check for missing and duplicate candidates + // Check for missing and duplicate candidates (only active ones) $missing = []; $duplicates = []; - foreach ($seasonCandidates as $candidate) { + foreach ($activeCandidates as $candidate) { $candidateId = $candidate->id->toString(); $count = $candidateCounts[$candidateId]['count'] ?? 0; - if ($count === 0) { + if (0 === $count) { $missing[] = $candidate->name; } elseif ($count > 1) { $duplicates[] = $candidate->name; @@ -149,16 +157,17 @@ class Quiz if (!empty($missing) || !empty($duplicates)) { $errors = []; if (!empty($missing)) { - // If all candidates are missing, show a special message - if (count($missing) === $seasonCandidates->count()) { + // If all active candidates are missing, show a special message + if (\count($missing) === \count($activeCandidates)) { $errors[] = 'No candidates assigned to this question'; } else { - $errors[] = 'Missing candidates: ' . implode(', ', $missing); + $errors[] = 'Missing candidates: '.implode(', ', $missing); } } if (!empty($duplicates)) { - $errors[] = 'Duplicate candidates: ' . implode(', ', $duplicates); + $errors[] = 'Duplicate candidates: '.implode(', ', $duplicates); } + return implode('. ', $errors); } } diff --git a/src/Entity/QuizCandidate.php b/src/Entity/QuizCandidate.php index e77fff7..20f2c5d 100644 --- a/src/Entity/QuizCandidate.php +++ b/src/Entity/QuizCandidate.php @@ -27,6 +27,12 @@ class QuizCandidate #[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])] public int $penaltySeconds = 0; + #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])] + public bool $active = true; + + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)] + public ?\DateTimeImmutable $started = null; + #[Gedmo\Timestampable(on: 'create')] #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)] public private(set) \DateTimeImmutable $created; diff --git a/src/Repository/QuizCandidateRepository.php b/src/Repository/QuizCandidateRepository.php index da50c62..a9b32cb 100644 --- a/src/Repository/QuizCandidateRepository.php +++ b/src/Repository/QuizCandidateRepository.php @@ -20,14 +20,28 @@ class QuizCandidateRepository extends ServiceEntityRepository parent::__construct($registry, QuizCandidate::class); } - /** @return bool true if a new entry was created */ - public function createIfNotExist(Quiz $quiz, Candidate $candidate): bool + /** @return bool|null true if a new entry was created, false if it already exists, null if candidate is inactive */ + public function createIfNotExist(Quiz $quiz, Candidate $candidate): ?bool { - if (0 !== $this->count(['candidate' => $candidate, 'quiz' => $quiz])) { + $quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]); + + if (null !== $quizCandidate) { + // Check if candidate is inactive + if (!$quizCandidate->active) { + return null; + } + + // If QuizCandidate exists but hasn't started yet, set the started timestamp + if (null === $quizCandidate->started) { + $quizCandidate->started = new \DateTimeImmutable(); + $this->getEntityManager()->flush(); + } + return false; } $quizCandidate = new QuizCandidate($quiz, $candidate); + $quizCandidate->started = new \Safe\DateTimeImmutable(); $this->getEntityManager()->persist($quizCandidate); $this->getEntityManager()->flush(); diff --git a/src/Repository/QuizRepository.php b/src/Repository/QuizRepository.php index 5190687..013decb 100644 --- a/src/Repository/QuizRepository.php +++ b/src/Repository/QuizRepository.php @@ -84,15 +84,15 @@ class QuizRepository extends ServiceEntityRepository qd.corrections, qd.penaltySeconds, max(ga.created) as end_time, - qd.created as start_time, + qd.started as start_time, (sum(case when a.isRightAnswer = true then 1 else 0 end) + qd.corrections) as score from Tvdt\Entity\Candidate c join c.givenAnswers ga join ga.answer a join c.quizData qd - where qd.quiz = :quiz and ga.quiz = :quiz + where qd.quiz = :quiz and ga.quiz = :quiz and qd.started is not null group by ga.quiz, c.id, qd.id - order by score desc, max(ga.created) - qd.created asc + order by score desc, max(ga.created) - qd.started asc DQL )->setParameter('quiz', $quiz)->getResult(); @@ -124,12 +124,14 @@ class QuizRepository extends ServiceEntityRepository public function fetchWithQuestionsAndCandidates(Uuid $id): Quiz { return $this->getEntityManager()->createQuery(<<setParameter('id', $id)->getSingleResult(); } diff --git a/templates/backoffice/quiz/tab_overview.html.twig b/templates/backoffice/quiz/tab_overview.html.twig index bb2f1e5..0cb01a2 100644 --- a/templates/backoffice/quiz/tab_overview.html.twig +++ b/templates/backoffice/quiz/tab_overview.html.twig @@ -56,6 +56,67 @@ {% endfor %} +

{{ 'Candidates'|trans }}

+ + + + + + + + + + + {% for candidate in season.candidates %} + {% set quizCandidate = null %} + {% for qc in quiz.candidateData %} + {% if qc.candidate.id.toString == candidate.id.toString %} + {% set quizCandidate = qc %} + {% endif %} + {% endfor %} + + {% set givenAnswersCount = 0 %} + {% for givenAnswer in candidate.givenAnswers %} + {% if givenAnswer.quiz.id.toString == quiz.id.toString %} + {% set givenAnswersCount = givenAnswersCount + 1 %} + {% endif %} + {% endfor %} + + + + + + + + {% endfor %} + +
{{ 'Name'|trans }}{{ 'Quiz Status'|trans }}{{ 'Candidate Status'|trans }}{{ 'Actions'|trans }}
{{ candidate.name }} + {% if quizCandidate and quizCandidate.started %} + {% if givenAnswersCount >= quiz.questions|length %} + {{ 'Completed'|trans }} + {% else %} + {{ 'In Progress'|trans }} ({{ givenAnswersCount }}/{{ quiz.questions|length }}) + {% endif %} + {% else %} + {{ 'Not Started'|trans }} + {% endif %} + + {% if quizCandidate == null or quizCandidate.active %} + {{ 'Active'|trans }} + {% else %} + {{ 'Inactive'|trans }} + {% endif %} + + + {% if quizCandidate == null or quizCandidate.active %} + {{ 'Deactivate'|trans }} + {% else %} + {{ 'Activate'|trans }} + {% endif %} + +
+ {# Modal Clear #}