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);
|
$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
@@ -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 = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user