mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-07 16:10:15 +02:00
Refactor candidate-related logic to optimize queries, improve template separation, and add "Answer Mapping" functionality.
This commit is contained in:
@@ -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
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user