Add correction management to backoffice, refactor security voter logic, and enhance candidate scoring

This commit introduces functionality to manage candidate corrections in the backoffice, with updated templates and a new route handler. The SeasonVoter is refactored to support additional entities, and scoring logic is updated to incorporate corrections consistently. Includes test coverage for voter logic and UI improvements for score tables.
This commit is contained in:
2025-06-07 16:05:15 +02:00
parent beb8d13dde
commit 79236d84e9
10 changed files with 182 additions and 33 deletions

View File

@@ -5,13 +5,18 @@ declare(strict_types=1);
namespace App\Controller\Backoffice;
use App\Controller\AbstractController;
use App\Entity\Candidate;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Repository\CandidateRepository;
use App\Repository\QuizCandidateRepository;
use App\Security\Voter\SeasonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -23,7 +28,9 @@ class QuizController extends AbstractController
private readonly CandidateRepository $candidateRepository,
) {}
#[Route('/backoffice/season/{seasonCode}/quiz/{quiz}', name: 'app_backoffice_quiz',
#[Route(
'/backoffice/season/{seasonCode}/quiz/{quiz}',
name: 'app_backoffice_quiz',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
)]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -36,11 +43,13 @@ class QuizController extends AbstractController
]);
}
#[Route('/backoffice/season/{seasonCode}/quiz/{quiz}/enable', name: 'app_backoffice_enable',
#[Route(
'/backoffice/season/{seasonCode}/quiz/{quiz}/enable',
name: 'app_backoffice_enable',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
)]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): Response
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): RedirectResponse
{
$season->setActiveQuiz($quiz);
$em->flush();
@@ -51,4 +60,22 @@ class QuizController extends AbstractController
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
#[Route(
'/backoffice/quiz/{quiz}/modify_correction/{candidate}',
name: 'app_backoffice_modify_correction',
)]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
public function modifyCorrection(Quiz $quiz, Candidate $candidate, QuizCandidateRepository $quizCandidateRepository, Request $request): RedirectResponse
{
if (!$request->isMethod('POST')) {
throw new MethodNotAllowedHttpException(['POST']);
}
$corrections = (float) $request->request->get('corrections');
$quizCandidateRepository->setCorrectionsForCandidate($quiz, $candidate, $corrections);
return $this->redirectToRoute('app_backoffice_quiz', ['seasonCode' => $quiz->getSeason()->getSeasonCode(), 'quiz' => $quiz->getId()]);
}
}

View File

@@ -21,10 +21,10 @@ use App\Repository\QuestionRepository;
use App\Repository\QuizCandidateRepository;
use App\Repository\SeasonRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -109,7 +109,7 @@ final class QuizController extends AbstractController
$answer = $answerRepository->findOneBy(['id' => $request->request->get('answer')]);
if (!$answer instanceof Answer) {
throw new BadRequestException('Invalid Answer ID');
throw new BadRequestHttpException('Invalid Answer ID');
}
$givenAnswer = new GivenAnswer($candidate, $answer->getQuestion()->getQuiz(), $answer);

View File

@@ -16,7 +16,7 @@ use Symfony\Component\Uid\Uuid;
/**
* @extends ServiceEntityRepository<Candidate>
*
* @phpstan-type Result array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float, score: float}
* @phpstan-type Result array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections: float, score: float}
* @phpstan-type ResultList list<Result>
*/
class CandidateRepository extends ServiceEntityRepository
@@ -54,40 +54,32 @@ class CandidateRepository extends ServiceEntityRepository
/** @return ResultList */
public function getScores(Quiz $quiz): array
{
$scoreQb = $this->createQueryBuilder('c', 'c.id')
->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct')
$qb = $this->createQueryBuilder('c', 'c.id')
->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'qc.corrections', 'max(ga.created) - qc.created as time')
->join('c.givenAnswers', 'ga')
->join('ga.answer', 'a')
->where('ga.quiz = :quiz')
->groupBy('c.id')
->setParameter('quiz', $quiz);
$startTimeCorrectionQb = $this->createQueryBuilder('c', 'c.id')
->select('c.id', 'qc.corrections', 'max(ga.created) - qc.created as time')
->join('c.quizData', 'qc')
->join('c.givenAnswers', 'ga')
->where('qc.quiz = :quiz')
->groupBy('ga.quiz', 'c.id', 'qc.id')
->setParameter('quiz', $quiz);
$merged = array_merge_recursive(
$scoreQb->getQuery()->getArrayResult(),
$startTimeCorrectionQb->getQuery()->getArrayResult(),
return $this->sortResults(
$this->calculateScore(
$qb->getQuery()->getResult(),
),
);
return $this->sortResults($this->calculateScore($merged));
}
/**
* @param array<string, array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float}> $in
* @param array<string, array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections: float}> $in
*
* @return array<string, Result>
* */
*/
private function calculateScore(array $in): array
{
return array_map(static fn ($candidate): array => [
...$candidate,
'score' => $candidate['correct'] + ($candidate['corrections'] ?? 0.0),
'score' => $candidate['correct'] + $candidate['corrections'],
], $in);
}

View File

@@ -33,4 +33,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
return true;
}
public function setCorrectionsForCandidate(Quiz $quiz, Candidate $candidate, float $corrections): void
{
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
if (!$quizCandidate instanceof QuizCandidate) {
throw new \InvalidArgumentException('Quiz candidate not found');
}
$quizCandidate->setCorrections($corrections);
$this->getEntityManager()->flush();
}
}

View File

@@ -4,7 +4,11 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Answer;
use App\Entity\Candidate;
use App\Entity\Elimination;
use App\Entity\Question;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -22,10 +26,17 @@ final class SeasonVoter extends Voter
protected function supports(string $attribute, mixed $subject): bool
{
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true)
&& ($subject instanceof Season || $subject instanceof Elimination);
&& (
$subject instanceof Season
|| $subject instanceof Elimination
|| $subject instanceof Quiz
|| $subject instanceof Candidate
|| $subject instanceof Answer
|| $subject instanceof Question
);
}
/** @param Season|Elimination $subject */
/** @param Season|Elimination|Quiz|Candidate|Answer|Question $subject */
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
@@ -37,7 +48,24 @@ final class SeasonVoter extends Voter
return true;
}
$season = $subject instanceof Season ? $subject : $subject->getQuiz()->getSeason();
switch (true) {
case $subject instanceof Answer:
$season = $subject->getQuestion()->getQuiz()->getSeason();
break;
case $subject instanceof Elimination:
case $subject instanceof Question:
$season = $subject->getQuiz()->getSeason();
break;
case $subject instanceof Candidate:
case $subject instanceof Quiz:
$season = $subject->getSeason();
break;
case $subject instanceof Season:
$season = $subject;
break;
default:
return false;
}
return match ($attribute) {
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),