mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-11 20:38:21 +02:00
feat: add reset progress action for a candidate's quiz attempt
Admins previously had no way to let a candidate retake a quiz once started. Adds a backoffice action that clears a candidate's given answers and start timestamp for a quiz, closes #20.
This commit is contained in:
@@ -25,6 +25,7 @@ use Tvdt\Entity\QuizCandidate;
|
|||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Enum\FlashType;
|
use Tvdt\Enum\FlashType;
|
||||||
use Tvdt\Exception\ErrorClearingQuizException;
|
use Tvdt\Exception\ErrorClearingQuizException;
|
||||||
|
use Tvdt\Repository\GivenAnswerRepository;
|
||||||
use Tvdt\Repository\QuizCandidateRepository;
|
use Tvdt\Repository\QuizCandidateRepository;
|
||||||
use Tvdt\Repository\QuizRepository;
|
use Tvdt\Repository\QuizRepository;
|
||||||
use Tvdt\Security\Voter\SeasonVoter;
|
use Tvdt\Security\Voter\SeasonVoter;
|
||||||
@@ -37,6 +38,7 @@ class QuizController extends AbstractController
|
|||||||
private readonly QuizRepository $quizRepository,
|
private readonly QuizRepository $quizRepository,
|
||||||
private readonly TranslatorInterface $translator,
|
private readonly TranslatorInterface $translator,
|
||||||
private readonly QuizCandidateRepository $quizCandidateRepository,
|
private readonly QuizCandidateRepository $quizCandidateRepository,
|
||||||
|
private readonly GivenAnswerRepository $givenAnswerRepository,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -397,6 +399,24 @@ class QuizController extends AbstractController
|
|||||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[IsCsrfTokenValid('reset_candidate_progress')]
|
||||||
|
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||||
|
#[Route(
|
||||||
|
'/backoffice/quiz/{quiz}/candidate/{candidate}/reset',
|
||||||
|
name: 'tvdt_backoffice_reset_candidate_progress',
|
||||||
|
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
|
||||||
|
methods: ['POST'],
|
||||||
|
)]
|
||||||
|
public function resetCandidateProgress(Quiz $quiz, Candidate $candidate): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->givenAnswerRepository->deleteAllForCandidateInQuiz($quiz, $candidate);
|
||||||
|
$this->quizCandidateRepository->resetProgressForCandidate($quiz, $candidate);
|
||||||
|
|
||||||
|
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate progress reset'));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates.
|
* Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ namespace Tvdt\Repository;
|
|||||||
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use Tvdt\Entity\Candidate;
|
||||||
use Tvdt\Entity\GivenAnswer;
|
use Tvdt\Entity\GivenAnswer;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
|
||||||
/** @extends ServiceEntityRepository<GivenAnswer> */
|
/** @extends ServiceEntityRepository<GivenAnswer> */
|
||||||
class GivenAnswerRepository extends ServiceEntityRepository
|
class GivenAnswerRepository extends ServiceEntityRepository
|
||||||
@@ -15,4 +17,15 @@ class GivenAnswerRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
parent::__construct($registry, GivenAnswer::class);
|
parent::__construct($registry, GivenAnswer::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteAllForCandidateInQuiz(Quiz $quiz, Candidate $candidate): void
|
||||||
|
{
|
||||||
|
$givenAnswers = $this->findBy(['quiz' => $quiz, 'candidate' => $candidate]);
|
||||||
|
|
||||||
|
foreach ($givenAnswers as $givenAnswer) {
|
||||||
|
$this->getEntityManager()->remove($givenAnswer);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
|
|||||||
$quizCandidate->penaltySeconds = $penalty;
|
$quizCandidate->penaltySeconds = $penalty;
|
||||||
$this->getEntityManager()->flush();
|
$this->getEntityManager()->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resetProgressForCandidate(Quiz $quiz, Candidate $candidate): void
|
||||||
|
{
|
||||||
|
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
|
||||||
|
if (!$quizCandidate instanceof QuizCandidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$quizCandidate->started = null;
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST">
|
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline">
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
|
<input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||||
{% if quizCandidate == null or quizCandidate.active %}
|
{% if quizCandidate == null or quizCandidate.active %}
|
||||||
@@ -47,6 +47,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if quizCandidate and quizCandidate.started %}
|
||||||
|
<form action="{{ path('tvdt_backoffice_reset_candidate_progress', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline" onsubmit="return confirm('{{ 'Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.'|trans|e('js') }}');">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('reset_candidate_progress') }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">{{ 'Reset progress'|trans }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -173,6 +173,47 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
|
|||||||
$this->assertTrue($updated->active);
|
$this->assertTrue($updated->active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testResetCandidateProgressDeletesGivenAnswersAndClearsStarted(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$candidate = $this->getCandidate('Tom');
|
||||||
|
|
||||||
|
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||||
|
$quizCandidate->started = new DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->entityManager->persist($quizCandidate);
|
||||||
|
$firstQuestion = $quiz->questions->first();
|
||||||
|
$this->assertInstanceOf(Question::class, $firstQuestion);
|
||||||
|
$answer = $firstQuestion->answers->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $answer);
|
||||||
|
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter(\sprintf('form[action*="/%s/reset"] input[name="_token"]', $candidate->id))->first()->attr('value');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/reset', $quiz->id, $candidate->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
|
||||||
|
'quiz' => $this->getQuizByName('Quiz 1'),
|
||||||
|
'candidate' => $this->getCandidate('Tom'),
|
||||||
|
]);
|
||||||
|
$this->assertInstanceOf(QuizCandidate::class, $updated);
|
||||||
|
$this->assertNotInstanceOf(\DateTimeImmutable::class, $updated->started);
|
||||||
|
|
||||||
|
$remainingAnswers = $this->entityManager->getRepository(GivenAnswer::class)->findBy([
|
||||||
|
'quiz' => $quiz,
|
||||||
|
'candidate' => $candidate,
|
||||||
|
]);
|
||||||
|
$this->assertCount(0, $remainingAnswers);
|
||||||
|
}
|
||||||
|
|
||||||
public function testModifyCorrection(): void
|
public function testModifyCorrection(): void
|
||||||
{
|
{
|
||||||
$quiz = $this->getQuizByName('Quiz 1');
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
|||||||
Reference in New Issue
Block a user