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

This commit is contained in:
2026-03-10 09:13:38 +01:00
parent af60a51bb3
commit 1370f61dc3
7 changed files with 160 additions and 77 deletions
+62 -4
View File
@@ -57,10 +57,31 @@ class QuizController extends AbstractController
{ {
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id); $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', [ return $this->render('backoffice/quiz.html.twig', [
'season' => $season, 'season' => $season,
'quiz' => $fetchedQuiz, 'quiz' => $fetchedQuiz,
'questionErrors' => $fetchedQuiz->getQuestionErrors(), 'questionErrors' => $fetchedQuiz->getQuestionErrors(),
'candidateData' => $candidateData,
'activeTab' => 'overview', 'activeTab' => 'overview',
'template' => 'backoffice/quiz/tab_overview.html.twig', 'template' => 'backoffice/quiz/tab_overview.html.twig',
]); ]);
@@ -87,11 +108,48 @@ class QuizController extends AbstractController
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route( #[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates', '/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,
'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', name: 'tvdt_backoffice_quiz_candidates',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
)] )]
public function candidates(Season $season, Quiz $quiz): Response public function answerMapping(Season $season, Quiz $quiz): Response
{ {
$fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id);
\assert($fetchedQuiz->questions->count() > 0); \assert($fetchedQuiz->questions->count() > 0);
@@ -119,7 +177,7 @@ class QuizController extends AbstractController
'quiz' => $quiz, 'quiz' => $quiz,
'question' => $question, 'question' => $question,
'candidates' => $season->candidates, 'candidates' => $season->candidates,
'activeTab' => 'candidates', 'activeTab' => 'answers',
'template' => 'backoffice/quiz/tab_candidates.html.twig', 'template' => 'backoffice/quiz/tab_candidates.html.twig',
]); ]);
} }
@@ -278,6 +336,6 @@ class QuizController extends AbstractController
$this->addFlash('success', $this->translator->trans('Candidate status updated')); $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']); return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
} }
} }
+15 -9
View File
@@ -90,8 +90,18 @@ class Quiz
} }
} }
// 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) { foreach ($this->questions as $question) {
$error = $this->getQuestionError($question, $hasCandidateRelations); $error = $this->getQuestionError($question, $hasCandidateRelations, $activeCandidates);
if (null !== $error) { if (null !== $error) {
$errors[$question->id->toString()] = $error; $errors[$question->id->toString()] = $error;
} }
@@ -100,7 +110,10 @@ class Quiz
return $errors; return $errors;
} }
private function getQuestionError(Question $question, bool $hasCandidateRelations): ?string /**
* @param list<Candidate> $activeCandidates
*/
private function getQuestionError(Question $question, bool $hasCandidateRelations, array $activeCandidates): ?string
{ {
if (0 === \count($question->answers)) { if (0 === \count($question->answers)) {
return 'This question has no answers'; return 'This question has no answers';
@@ -118,13 +131,6 @@ class Quiz
// Only validate candidate-answer relations if at least one exists in the quiz // Only validate candidate-answer relations if at least one exists in the quiz
if ($hasCandidateRelations) { if ($hasCandidateRelations) {
// Get only active candidates for this quiz
$activeCandidates = [];
foreach ($this->candidateData as $quizCandidate) {
if ($quizCandidate->active) {
$activeCandidates[] = $quizCandidate->candidate;
}
}
$candidateCounts = []; $candidateCounts = [];
+27 -2
View File
@@ -128,15 +128,40 @@ class QuizRepository extends ServiceEntityRepository
public function fetchWithQuestionsAndCandidates(Uuid $id): Quiz public function fetchWithQuestionsAndCandidates(Uuid $id): Quiz
{ {
return $this->getEntityManager()->createQuery(<<<dql return $this->getEntityManager()->createQuery(<<<dql
select q, qz, a, ac, s, sc, qc, ga from Tvdt\Entity\Quiz q select q, qz, a, ac, s, sc, qc from Tvdt\Entity\Quiz q
join q.questions qz join q.questions qz
join qz.answers a join qz.answers a
left join a.candidates ac left join a.candidates ac
join q.season s join q.season s
left join s.candidates sc left join s.candidates sc
left join q.candidateData qc left join q.candidateData qc
left join sc.givenAnswers ga with ga.quiz = q
where q.id = :id where q.id = :id
dql)->setParameter('id', $id)->getSingleResult(); 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;
}
} }
+2 -1
View File
@@ -15,8 +15,9 @@
{% block body %} {% block body %}
{% set tabs = [ {% set tabs = [
{id: 'overview', label: 'Overview'|trans, route: 'tvdt_backoffice_quiz_overview'}, {id: 'overview', label: 'Overview'|trans, route: 'tvdt_backoffice_quiz_overview'},
{id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_quiz_candidates_tab'},
{id: 'result', label: 'Results & Elimination'|trans, route: 'tvdt_backoffice_quiz_result'}, {id: 'result', label: 'Results & Elimination'|trans, route: 'tvdt_backoffice_quiz_result'},
{id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_quiz_candidates'}, {id: 'answers', label: 'Answer Mapping'|trans, route: 'tvdt_backoffice_quiz_candidates'},
] %} ] %}
<h2 class="mb-3">{{ 'Quiz'|trans }}: {{ season.name }} - {{ quiz.name }}</h2> <h2 class="mb-3">{{ 'Quiz'|trans }}: {{ season.name }} - {{ quiz.name }}</h2>
@@ -0,0 +1,50 @@
<h4 class="mb-3">{{ 'Candidates'|trans }}</h4>
<table class="table table-hover mb-3">
<thead>
<tr>
<th scope="col">{{ 'Name'|trans }}</th>
<th scope="col">{{ 'Quiz Status'|trans }}</th>
<th scope="col">{{ 'Candidate Status'|trans }}</th>
<th scope="col">{{ 'Actions'|trans }}</th>
</tr>
</thead>
<tbody>
{% for data in candidateData %}
{% set candidate = data.candidate %}
{% set quizCandidate = data.quizCandidate %}
{% set givenAnswersCount = data.givenAnswersCount %}
<tr>
<td>{{ candidate.name }}</td>
<td>
{% if quizCandidate and quizCandidate.started %}
{% if givenAnswersCount >= quiz.questions|length %}
<span class="badge text-bg-success">{{ 'Completed'|trans }}</span>
{% else %}
<span class="badge text-bg-warning">{{ 'In Progress'|trans }} ({{ givenAnswersCount }}/{{ quiz.questions|length }})</span>
{% endif %}
{% else %}
<span class="badge text-bg-secondary">{{ 'Not Started'|trans }}</span>
{% endif %}
</td>
<td>
{% if quizCandidate == null or quizCandidate.active %}
<span class="badge text-bg-success">{{ 'Active'|trans }}</span>
{% else %}
<span class="badge text-bg-secondary">{{ 'Inactive'|trans }}</span>
{% endif %}
</td>
<td>
<a href="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}"
class="btn btn-sm btn-outline-secondary">
{% if quizCandidate == null or quizCandidate.active %}
{{ 'Deactivate'|trans }}
{% else %}
{{ 'Activate'|trans }}
{% endif %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
@@ -56,67 +56,6 @@
{% endfor %} {% endfor %}
</div> </div>
<h4 class="mb-3 mt-4" id="candidates">{{ 'Candidates'|trans }}</h4>
<table class="table table-hover mb-3">
<thead>
<tr>
<th scope="col">{{ 'Name'|trans }}</th>
<th scope="col">{{ 'Quiz Status'|trans }}</th>
<th scope="col">{{ 'Candidate Status'|trans }}</th>
<th scope="col">{{ 'Actions'|trans }}</th>
</tr>
</thead>
<tbody>
{% 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 %}
<tr>
<td>{{ candidate.name }}</td>
<td>
{% if quizCandidate and quizCandidate.started %}
{% if givenAnswersCount >= quiz.questions|length %}
<span class="badge text-bg-success">{{ 'Completed'|trans }}</span>
{% else %}
<span class="badge text-bg-warning">{{ 'In Progress'|trans }} ({{ givenAnswersCount }}/{{ quiz.questions|length }})</span>
{% endif %}
{% else %}
<span class="badge text-bg-secondary">{{ 'Not Started'|trans }}</span>
{% endif %}
</td>
<td>
{% if quizCandidate == null or quizCandidate.active %}
<span class="badge text-bg-success">{{ 'Active'|trans }}</span>
{% else %}
<span class="badge text-bg-secondary">{{ 'Inactive'|trans }}</span>
{% endif %}
</td>
<td>
<a href="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}"
class="btn btn-sm btn-outline-secondary">
{% if quizCandidate == null or quizCandidate.active %}
{{ 'Deactivate'|trans }}
{% else %}
{{ 'Activate'|trans }}
{% endif %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Modal Clear #} {# Modal Clear #}
<div class="modal fade" id="clearQuizModal" data-bs-backdrop="static" <div class="modal fade" id="clearQuizModal" data-bs-backdrop="static"
tabindex="-1" tabindex="-1"
+4
View File
@@ -49,6 +49,10 @@
<source>Already have an account? Log in</source> <source>Already have an account? Log in</source>
<target>Heb je al een account? Log in</target> <target>Heb je al een account? Log in</target>
</trans-unit> </trans-unit>
<trans-unit id="3A2JPqn" resname="Answer Mapping">
<source>Answer Mapping</source>
<target>Antwoord-kandidaat koppeling</target>
</trans-unit>
<trans-unit id="Qu1euq_" resname="Are you sure you want to clear all the results? This will also delete al the eliminations."> <trans-unit id="Qu1euq_" resname="Are you sure you want to clear all the results? This will also delete al the eliminations.">
<source>Are you sure you want to clear all the results? This will also delete al the eliminations.</source> <source>Are you sure you want to clear all the results? This will also delete al the eliminations.</source>
<target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target> <target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>