Answer on candidate (#72)

* Add Penalty Seconds on tests

* Refactors and start of candidate answer relation

* Add breadcrumbs and UI consistency updates across backoffice templates

* Add breadcrumbs and UI consistency updates across backoffice templates

* Add Dutch translations for email verification and security messages

* Rector

* Refactor for code consistency and type safety assertions across repositories and entities

* Refactor candidate-related logic to optimize queries, improve template separation, and add "Answer Mapping" functionality.

* Cleanup

* Update Symfony

* Add coderabbit config

* Fixes from coderabbit
This commit is contained in:
2026-03-22 22:40:25 +01:00
committed by GitHub
parent d0896ceec7
commit 18a6090366
56 changed files with 2389 additions and 580 deletions
+241 -1
View File
@@ -9,14 +9,18 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Attribute\Route;
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;
@@ -41,10 +45,197 @@ class QuizController extends AbstractController
)]
public function index(Season $season, Quiz $quiz): Response
{
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/overview',
name: 'tvdt_backoffice_quiz_overview',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
)]
public function overview(Season $season, Quiz $quiz): Response
{
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id);
// Create indexed lookup for quiz candidates by candidate ID
$quizCandidatesByCandidateId = [];
foreach ($fetchedQuiz->candidateData as $qc) {
$quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc;
}
// Get given answers counts efficiently via database query
$givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz);
// Pre-compute candidate data to avoid nested loops in template
$candidateData = [];
foreach ($season->candidates as $candidate) {
$candidateIdString = $candidate->id->toString();
$candidateData[] = [
'candidate' => $candidate,
'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null,
'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0,
];
}
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
'quiz' => $fetchedQuiz,
'questionErrors' => $fetchedQuiz->getQuestionErrors(),
'candidateData' => $candidateData,
'activeTab' => 'overview',
'template' => 'backoffice/quiz/tab_overview.html.twig',
]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/result',
name: 'tvdt_backoffice_quiz_result',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
)]
public function result(Season $season, Quiz $quiz): Response
{
$fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id);
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
'quiz' => $fetchedQuiz,
'result' => $this->quizRepository->getScores($quiz),
'activeTab' => 'result',
'template' => 'backoffice/quiz/tab_result.html.twig',
]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates-list',
name: 'tvdt_backoffice_quiz_candidates_tab',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
)]
public function candidatesTab(Season $season, Quiz $quiz): Response
{
// Create indexed lookup for quiz candidates by candidate ID
$quizCandidatesByCandidateId = [];
foreach ($quiz->candidateData as $qc) {
$quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc;
}
// Get given answers counts efficiently via database query
$givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz);
// Pre-compute candidate data to avoid nested loops in template
$candidateData = [];
foreach ($season->candidates as $candidate) {
$candidateIdString = $candidate->id->toString();
$candidateData[] = [
'candidate' => $candidate,
'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null,
'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0,
];
}
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
'quiz' => $quiz,
'result' => $this->quizRepository->getScores($quiz),
'candidateData' => $candidateData,
'activeTab' => 'candidates',
'template' => 'backoffice/quiz/tab_candidates_list.html.twig',
]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/answer-mapping',
name: 'tvdt_backoffice_quiz_candidates',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
)]
public function answerMapping(Season $season, Quiz $quiz): Response
{
$fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id);
\assert($fetchedQuiz->questions->count() > 0);
$firstQuestion = $fetchedQuiz->questions->first();
\assert($firstQuestion instanceof Question);
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
'question' => $firstQuestion->id,
]);
}
#[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'],
)]
public function candidates_question(Season $season, Quiz $quiz, Question $question): Response
{
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
'quiz' => $quiz,
'question' => $question,
'candidates' => $season->candidates,
'activeTab' => 'answers',
'template' => 'backoffice/quiz/tab_candidates.html.twig',
]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/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'],
)]
public function saveCandidateAnswers(Season $season, Quiz $quiz, Question $question, Request $request, EntityManagerInterface $em): RedirectResponse
{
if (false === $season->quizzes->contains($quiz)
|| false === $quiz->questions->contains($question)) {
throw new BadRequestHttpException('Invalid quiz or question');
}
$candidateAnswers = $request->request->all('candidate_answer');
// Clear existing candidate-answer associations for this question
foreach ($question->answers as $answer) {
if (false === $quiz->questions->contains($answer->question)) {
throw new BadRequestHttpException('Invalid question');
}
$answer->candidates->clear();
}
// Add new associations
foreach ($candidateAnswers as $candidateId => $answerIds) {
$candidate = $em->getRepository(Candidate::class)->find($candidateId);
if (false === $season->candidates->contains($candidate)) {
throw new BadRequestHttpException('Invalid candidate');
}
foreach ((array) $answerIds as $answerId) {
$answer = $em->getRepository(Answer::class)->find($answerId);
if (false === $question->answers->contains($answer)) {
throw new BadRequestHttpException('Invalid answer');
}
if ($answer && $candidate) {
$answer->addCandidate($candidate);
}
}
}
$em->flush();
$this->addFlash('success', $this->translator->trans('Candidate answers saved'));
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
'question' => $question->id,
]);
}
@@ -117,4 +308,53 @@ 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}/modify_penalty',
name: 'tvdt_backoffice_modify_penalty',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
)]
public function modifyPenalty(Quiz $quiz, Candidate $candidate, Request $request): RedirectResponse
{
if (!$request->isMethod('POST')) {
throw new MethodNotAllowedHttpException(['POST']);
}
$penalty = (int) $request->request->get('penalty');
$this->quizCandidateRepository->setPenaltyForCandidate($quiz, $candidate, $penalty);
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],
methods: ['GET'],
)]
public function toggleCandidate(Quiz $quiz, Candidate $candidate, EntityManagerInterface $em): RedirectResponse
{
$quizCandidate = $this->quizCandidateRepository->findOneBy([
'quiz' => $quiz,
'candidate' => $candidate,
]);
if (!$quizCandidate instanceof 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_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
}
+16 -1
View File
@@ -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]);
+1
View File
@@ -13,6 +13,7 @@ final readonly class Result
public string $name,
public int $correct,
public float $corrections,
public int $penaltySeconds,
public \DateInterval $time,
public float $score,
) {}
+6 -1
View File
@@ -13,7 +13,7 @@ use Symfony\Component\Uid\Uuid;
use Tvdt\Repository\AnswerRepository;
#[ORM\Entity(repositoryClass: AnswerRepository::class)]
class Answer
class Answer implements \Stringable
{
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
@@ -57,4 +57,9 @@ class Answer
{
$this->candidates->removeElement($candidate);
}
public function __toString(): string
{
return $this->text;
}
}
+3 -17
View File
@@ -13,7 +13,7 @@ use Symfony\Component\Uid\Uuid;
use Tvdt\Repository\QuestionRepository;
#[ORM\Entity(repositoryClass: QuestionRepository::class)]
class Question
class Question implements \Stringable
{
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
@@ -54,22 +54,8 @@ class Question
return $this;
}
public function getErrors(): ?string
public function __toString(): string
{
if (0 === \count($this->answers)) {
return 'This question has no answers';
}
$correctAnswers = $this->answers->filter(static fn (Answer $answer): bool => $answer->isRightAnswer)->count();
if (0 === $correctAnswers) {
return 'This question has no correct answers';
}
if ($correctAnswers > 1) {
return 'This question has multiple correct answers';
}
return null;
return $this->question ?? '';
}
}
+111
View File
@@ -68,4 +68,115 @@ class Quiz
return $this;
}
/**
* Get errors for all questions in the quiz.
* Returns an array where keys are question IDs and values are error messages.
*
* @return array<string, string>
*/
public function getQuestionErrors(): array
{
$errors = [];
// Check if any answer in the entire quiz has candidate relations
$hasCandidateRelations = false;
foreach ($this->questions as $question) {
foreach ($question->answers as $answer) {
if ($answer->candidates->count() > 0) {
$hasCandidateRelations = true;
break 2;
}
}
}
// Pre-compute active candidates once for all questions
$activeCandidates = [];
if ($hasCandidateRelations) {
foreach ($this->candidateData as $quizCandidate) {
if ($quizCandidate->active) {
$activeCandidates[] = $quizCandidate->candidate;
}
}
}
foreach ($this->questions as $question) {
$error = $this->getQuestionError($question, $hasCandidateRelations, $activeCandidates);
if (null !== $error) {
$errors[$question->id->toString()] = $error;
}
}
return $errors;
}
/** @param list<Candidate> $activeCandidates */
private function getQuestionError(Question $question, bool $hasCandidateRelations, array $activeCandidates): ?string
{
if (0 === \count($question->answers)) {
return 'This question has no answers';
}
$correctAnswers = $question->answers->filter(static fn (Answer $answer): bool => $answer->isRightAnswer)->count();
if (0 === $correctAnswers) {
return 'This question has no correct answers';
}
if ($correctAnswers > 1) {
return 'This question has multiple correct answers';
}
// Only validate candidate-answer relations if at least one exists in the quiz
if ($hasCandidateRelations) {
$candidateCounts = [];
// Count how many times each candidate appears in answers
foreach ($question->answers as $answer) {
foreach ($answer->candidates as $candidate) {
$candidateId = $candidate->id->toString();
if (!isset($candidateCounts[$candidateId])) {
$candidateCounts[$candidateId] = ['name' => $candidate->name, 'count' => 0];
}
++$candidateCounts[$candidateId]['count'];
}
}
// Check for missing and duplicate candidates (only active ones)
$missing = [];
$duplicates = [];
foreach ($activeCandidates as $candidate) {
$candidateId = $candidate->id->toString();
$count = $candidateCounts[$candidateId]['count'] ?? 0;
if (0 === $count) {
$missing[] = $candidate->name;
} elseif ($count > 1) {
$duplicates[] = $candidate->name;
}
}
if ([] !== $missing || [] !== $duplicates) {
$errors = [];
if ([] !== $missing) {
// 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);
}
}
if ([] !== $duplicates) {
$errors[] = 'Duplicate candidates: '.implode(', ', $duplicates);
}
return implode('. ', $errors);
}
}
return null;
}
}
+9
View File
@@ -24,6 +24,15 @@ class QuizCandidate
#[ORM\Column]
public float $corrections = 0;
#[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;
+2
View File
@@ -30,6 +30,7 @@ class Season
/** @var Collection<int, Quiz> */
#[ORM\OneToMany(targetEntity: Quiz::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['id' => 'ASC'])]
public private(set) Collection $quizzes;
/** @var Collection<int, Candidate> */
@@ -39,6 +40,7 @@ class Season
/** @var Collection<int, User> */
#[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'seasons')]
#[ORM\OrderBy(['email' => 'ASC'])]
public private(set) Collection $owners;
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
+5 -1
View File
@@ -17,12 +17,16 @@ class SettingsForm extends AbstractType
{
$builder
->add('showNumbers', options: [
'label' => 'Show Numbers',
'label_attr' => ['class' => 'checkbox-switch'],
'attr' => ['role' => 'switch', 'switch' => null]])
->add('confirmAnswers', options: [
'label' => 'Confirm Answers',
'label_attr' => ['class' => 'checkbox-switch'],
'attr' => ['role' => 'switch', 'switch' => null]])
->add('save', SubmitType::class)
->add('save', SubmitType::class, [
'label' => 'Save',
])
;
}
+29 -3
View File
@@ -6,6 +6,7 @@ namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Safe\DateTimeImmutable;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
@@ -20,14 +21,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 DateTimeImmutable();
$this->getEntityManager()->persist($quizCandidate);
$this->getEntityManager()->flush();
@@ -44,4 +59,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
$quizCandidate->corrections = $corrections;
$this->getEntityManager()->flush();
}
public function setPenaltyForCandidate(Quiz $quiz, Candidate $candidate, int $penalty): void
{
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
if (!$quizCandidate instanceof QuizCandidate) {
throw new \InvalidArgumentException('Quiz candidate not found');
}
$quizCandidate->penaltySeconds = $penalty;
$this->getEntityManager()->flush();
}
}
+72 -11
View File
@@ -9,6 +9,7 @@ use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Safe\DateTimeImmutable;
use Safe\Exceptions\DatetimeException;
use Symfony\Component\Uid\Uuid;
use Tvdt\Dto\Result;
use Tvdt\Entity\Quiz;
use Tvdt\Exception\ErrorClearingQuizException;
@@ -81,26 +82,86 @@ class QuizRepository extends ServiceEntityRepository
c.name,
sum(case when a.isRightAnswer = true then 1 else 0 end) as correct,
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();
return array_map(static fn (array $row): Result => new Result(
id: $row['id'],
name: $row['name'],
correct: (int) $row['correct'],
corrections: $row['corrections'],
time: $row['start_time']->diff(new DateTimeImmutable($row['end_time'])),
score: $row['score'],
), $result);
return array_map(static function (array $row): Result {
\assert($row['start_time'] instanceof \DateTimeImmutable);
return new Result(
id: $row['id'],
name: $row['name'],
correct: (int) $row['correct'],
corrections: $row['corrections'],
penaltySeconds: $row['penaltySeconds'],
time: $row['start_time']->diff(new DateTimeImmutable($row['end_time'])),
score: $row['score'],
);
}, $result);
}
public function fetchWithQuestions(Uuid $id): Quiz
{
return $this->getEntityManager()->createQuery(<<<dql
select q, qz, a from Tvdt\Entity\Quiz q
join q.questions qz
join qz.answers a
where q.id = :id
dql)->setParameter('id', $id)->getSingleResult();
}
/**
* Fetch quiz with all relations needed for error checking.
* This includes: questions, answers, answer candidates, and season candidates.
*/
public function fetchWithQuestionsAndCandidates(Uuid $id): Quiz
{
return $this->getEntityManager()->createQuery(<<<dql
select q, qz, a, ac, s, sc, qc from Tvdt\Entity\Quiz q
join q.questions qz
join qz.answers a
left join a.candidates ac
join q.season s
left join s.candidates sc
left join q.candidateData qc
where q.id = :id
dql)->setParameter('id', $id)->getSingleResult();
}
/**
* Get given answers count per candidate for a quiz.
*
* @return array<string, int> Array with candidate ID as key and count as value
*/
public function getGivenAnswersCountPerCandidate(Quiz $quiz): array
{
$results = $this->getEntityManager()->createQuery(<<<DQL
select c.id as candidateId, count(ga.id) as answerCount
from Tvdt\Entity\Candidate c
left join c.givenAnswers ga with ga.quiz = :quiz
where c.season = :season
group by c.id
DQL
)->setParameter('quiz', $quiz)
->setParameter('season', $quiz->season)
->getResult();
$counts = [];
foreach ($results as $row) {
$counts[$row['candidateId']->toString()] = (int) $row['answerCount'];
}
return $counts;
}
}