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);
// 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',
]);
@@ -87,11 +108,48 @@ class QuizController extends AbstractController
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[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',
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);
\assert($fetchedQuiz->questions->count() > 0);
@@ -119,7 +177,7 @@ class QuizController extends AbstractController
'quiz' => $quiz,
'question' => $question,
'candidates' => $season->candidates,
'activeTab' => 'candidates',
'activeTab' => 'answers',
'template' => 'backoffice/quiz/tab_candidates.html.twig',
]);
}
@@ -278,6 +336,6 @@ class QuizController extends AbstractController
$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) {
$error = $this->getQuestionError($question, $hasCandidateRelations);
$error = $this->getQuestionError($question, $hasCandidateRelations, $activeCandidates);
if (null !== $error) {
$errors[$question->id->toString()] = $error;
}
@@ -100,7 +110,10 @@ class Quiz
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)) {
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
if ($hasCandidateRelations) {
// Get only active candidates for this quiz
$activeCandidates = [];
foreach ($this->candidateData as $quizCandidate) {
if ($quizCandidate->active) {
$activeCandidates[] = $quizCandidate->candidate;
}
}
$candidateCounts = [];
+27 -2
View File
@@ -128,15 +128,40 @@ class QuizRepository extends ServiceEntityRepository
public function fetchWithQuestionsAndCandidates(Uuid $id): Quiz
{
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 qz.answers a
left join a.candidates ac
join q.season s
left join s.candidates sc
left join q.candidateData qc
left join sc.givenAnswers ga with ga.quiz = q
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;
}
}
+2 -1
View File
@@ -15,8 +15,9 @@
{% block body %}
{% set tabs = [
{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: '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>
@@ -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 %}
</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 #}
<div class="modal fade" id="clearQuizModal" data-bs-backdrop="static"
tabindex="-1"
+4
View File
@@ -49,6 +49,10 @@
<source>Already have an account? Log in</source>
<target>Heb je al een account? Log in</target>
</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.">
<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>