mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-04 22:50:15 +02:00
Answer on candidate (#72)
* Add Penalty Seconds on tests * Refactors and start of candidate answer relation * Add breadcrumbs and UI consistency updates across backoffice templates * Add breadcrumbs and UI consistency updates across backoffice templates * Add Dutch translations for email verification and security messages * Rector * Refactor for code consistency and type safety assertions across repositories and entities * Refactor candidate-related logic to optimize queries, improve template separation, and add "Answer Mapping" functionality. * Cleanup * Update Symfony * Add coderabbit config * Fixes from coderabbit
This commit is contained in:
@@ -9,14 +9,18 @@ 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\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
use Tvdt\Repository\QuizCandidateRepository;
|
||||
@@ -41,10 +45,197 @@ class QuizController extends AbstractController
|
||||
)]
|
||||
public function index(Season $season, Quiz $quiz): Response
|
||||
{
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/overview',
|
||||
name: 'tvdt_backoffice_quiz_overview',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
|
||||
)]
|
||||
public function overview(Season $season, Quiz $quiz): Response
|
||||
{
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/result',
|
||||
name: 'tvdt_backoffice_quiz_result',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
|
||||
)]
|
||||
public function result(Season $season, Quiz $quiz): Response
|
||||
{
|
||||
$fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id);
|
||||
|
||||
return $this->render('backoffice/quiz.html.twig', [
|
||||
'season' => $season,
|
||||
'quiz' => $fetchedQuiz,
|
||||
'result' => $this->quizRepository->getScores($quiz),
|
||||
'activeTab' => 'result',
|
||||
'template' => 'backoffice/quiz/tab_result.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/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,
|
||||
'result' => $this->quizRepository->getScores($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 answerMapping(Season $season, Quiz $quiz): Response
|
||||
{
|
||||
$fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id);
|
||||
\assert($fetchedQuiz->questions->count() > 0);
|
||||
$firstQuestion = $fetchedQuiz->questions->first();
|
||||
\assert($firstQuestion instanceof Question);
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [
|
||||
'seasonCode' => $season->seasonCode,
|
||||
'quiz' => $quiz->id,
|
||||
'question' => $firstQuestion->id,
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}',
|
||||
name: 'tvdt_backoffice_quiz_candidates_question',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
|
||||
methods: ['GET'],
|
||||
)]
|
||||
public function candidates_question(Season $season, Quiz $quiz, Question $question): Response
|
||||
{
|
||||
return $this->render('backoffice/quiz.html.twig', [
|
||||
'season' => $season,
|
||||
'quiz' => $quiz,
|
||||
'question' => $question,
|
||||
'candidates' => $season->candidates,
|
||||
'activeTab' => 'answers',
|
||||
'template' => 'backoffice/quiz/tab_candidates.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}',
|
||||
name: 'tvdt_backoffice_quiz_candidates_question_save',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function saveCandidateAnswers(Season $season, Quiz $quiz, Question $question, Request $request, EntityManagerInterface $em): RedirectResponse
|
||||
{
|
||||
if (false === $season->quizzes->contains($quiz)
|
||||
|| false === $quiz->questions->contains($question)) {
|
||||
throw new BadRequestHttpException('Invalid quiz or question');
|
||||
}
|
||||
$candidateAnswers = $request->request->all('candidate_answer');
|
||||
|
||||
// Clear existing candidate-answer associations for this question
|
||||
foreach ($question->answers as $answer) {
|
||||
if (false === $quiz->questions->contains($answer->question)) {
|
||||
throw new BadRequestHttpException('Invalid question');
|
||||
}
|
||||
|
||||
$answer->candidates->clear();
|
||||
}
|
||||
|
||||
// Add new associations
|
||||
foreach ($candidateAnswers as $candidateId => $answerIds) {
|
||||
$candidate = $em->getRepository(Candidate::class)->find($candidateId);
|
||||
|
||||
if (false === $season->candidates->contains($candidate)) {
|
||||
throw new BadRequestHttpException('Invalid candidate');
|
||||
}
|
||||
|
||||
foreach ((array) $answerIds as $answerId) {
|
||||
$answer = $em->getRepository(Answer::class)->find($answerId);
|
||||
|
||||
if (false === $question->answers->contains($answer)) {
|
||||
throw new BadRequestHttpException('Invalid answer');
|
||||
}
|
||||
|
||||
if ($answer && $candidate) {
|
||||
$answer->addCandidate($candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', $this->translator->trans('Candidate answers saved'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [
|
||||
'seasonCode' => $season->seasonCode,
|
||||
'quiz' => $quiz->id,
|
||||
'question' => $question->id,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -117,4 +308,53 @@ class QuizController extends AbstractController
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/candidate/{candidate}/modify_penalty',
|
||||
name: 'tvdt_backoffice_modify_penalty',
|
||||
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
|
||||
)]
|
||||
public function modifyPenalty(Quiz $quiz, Candidate $candidate, Request $request): RedirectResponse
|
||||
{
|
||||
if (!$request->isMethod('POST')) {
|
||||
throw new MethodNotAllowedHttpException(['POST']);
|
||||
}
|
||||
|
||||
$penalty = (int) $request->request->get('penalty');
|
||||
|
||||
$this->quizCandidateRepository->setPenaltyForCandidate($quiz, $candidate, $penalty);
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/candidate/{candidate}/toggle',
|
||||
name: 'tvdt_backoffice_toggle_candidate',
|
||||
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
|
||||
methods: ['GET'],
|
||||
)]
|
||||
public function toggleCandidate(Quiz $quiz, Candidate $candidate, EntityManagerInterface $em): RedirectResponse
|
||||
{
|
||||
$quizCandidate = $this->quizCandidateRepository->findOneBy([
|
||||
'quiz' => $quiz,
|
||||
'candidate' => $candidate,
|
||||
]);
|
||||
|
||||
if (!$quizCandidate instanceof QuizCandidate) {
|
||||
// Create new QuizCandidate if it doesn't exist (inactive by default when first toggling)
|
||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||
$quizCandidate->active = false;
|
||||
$em->persist($quizCandidate);
|
||||
} else {
|
||||
$quizCandidate->active = !$quizCandidate->active;
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', $this->translator->trans('Candidate status updated'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,14 @@ final class QuizController extends AbstractController
|
||||
|
||||
if ('POST' === $request->getMethod()) {
|
||||
// TODO: Extract saving answer logic to a service
|
||||
// Check if candidate is inactive for this quiz
|
||||
$quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]);
|
||||
if (null !== $quizCandidate && !$quizCandidate->active) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('You are not allowed to answer this quiz'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
$answer = $this->answerRepository->findOneBy(['id' => $request->request->get('answer')]);
|
||||
|
||||
if (!$answer instanceof Answer) {
|
||||
@@ -123,7 +131,14 @@ final class QuizController extends AbstractController
|
||||
return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
|
||||
$result = $this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
|
||||
|
||||
// Check if candidate is inactive
|
||||
if (null === $result) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('You are not allowed to answer this quiz'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
// end of extracting getting next question logic
|
||||
return $this->render('quiz/question.twig', ['candidate' => $candidate, 'question' => $question, 'season' => $season]);
|
||||
|
||||
@@ -13,6 +13,7 @@ final readonly class Result
|
||||
public string $name,
|
||||
public int $correct,
|
||||
public float $corrections,
|
||||
public int $penaltySeconds,
|
||||
public \DateInterval $time,
|
||||
public float $score,
|
||||
) {}
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\Uid\Uuid;
|
||||
use Tvdt\Repository\AnswerRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AnswerRepository::class)]
|
||||
class Answer
|
||||
class Answer implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
@@ -57,4 +57,9 @@ class Answer
|
||||
{
|
||||
$this->candidates->removeElement($candidate);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-17
@@ -13,7 +13,7 @@ use Symfony\Component\Uid\Uuid;
|
||||
use Tvdt\Repository\QuestionRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: QuestionRepository::class)]
|
||||
class Question
|
||||
class Question implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
@@ -54,22 +54,8 @@ class Question
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getErrors(): ?string
|
||||
public function __toString(): string
|
||||
{
|
||||
if (0 === \count($this->answers)) {
|
||||
return 'This question has no answers';
|
||||
}
|
||||
|
||||
$correctAnswers = $this->answers->filter(static fn (Answer $answer): bool => $answer->isRightAnswer)->count();
|
||||
|
||||
if (0 === $correctAnswers) {
|
||||
return 'This question has no correct answers';
|
||||
}
|
||||
|
||||
if ($correctAnswers > 1) {
|
||||
return 'This question has multiple correct answers';
|
||||
}
|
||||
|
||||
return null;
|
||||
return $this->question ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,115 @@ class Quiz
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get errors for all questions in the quiz.
|
||||
* Returns an array where keys are question IDs and values are error messages.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getQuestionErrors(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check if any answer in the entire quiz has candidate relations
|
||||
$hasCandidateRelations = false;
|
||||
foreach ($this->questions as $question) {
|
||||
foreach ($question->answers as $answer) {
|
||||
if ($answer->candidates->count() > 0) {
|
||||
$hasCandidateRelations = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, $activeCandidates);
|
||||
if (null !== $error) {
|
||||
$errors[$question->id->toString()] = $error;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/** @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';
|
||||
}
|
||||
|
||||
$correctAnswers = $question->answers->filter(static fn (Answer $answer): bool => $answer->isRightAnswer)->count();
|
||||
|
||||
if (0 === $correctAnswers) {
|
||||
return 'This question has no correct answers';
|
||||
}
|
||||
|
||||
if ($correctAnswers > 1) {
|
||||
return 'This question has multiple correct answers';
|
||||
}
|
||||
|
||||
// Only validate candidate-answer relations if at least one exists in the quiz
|
||||
if ($hasCandidateRelations) {
|
||||
$candidateCounts = [];
|
||||
|
||||
// Count how many times each candidate appears in answers
|
||||
foreach ($question->answers as $answer) {
|
||||
foreach ($answer->candidates as $candidate) {
|
||||
$candidateId = $candidate->id->toString();
|
||||
if (!isset($candidateCounts[$candidateId])) {
|
||||
$candidateCounts[$candidateId] = ['name' => $candidate->name, 'count' => 0];
|
||||
}
|
||||
|
||||
++$candidateCounts[$candidateId]['count'];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing and duplicate candidates (only active ones)
|
||||
$missing = [];
|
||||
$duplicates = [];
|
||||
|
||||
foreach ($activeCandidates as $candidate) {
|
||||
$candidateId = $candidate->id->toString();
|
||||
$count = $candidateCounts[$candidateId]['count'] ?? 0;
|
||||
|
||||
if (0 === $count) {
|
||||
$missing[] = $candidate->name;
|
||||
} elseif ($count > 1) {
|
||||
$duplicates[] = $candidate->name;
|
||||
}
|
||||
}
|
||||
|
||||
if ([] !== $missing || [] !== $duplicates) {
|
||||
$errors = [];
|
||||
if ([] !== $missing) {
|
||||
// If all active candidates are missing, show a special message
|
||||
if (\count($missing) === \count($activeCandidates)) {
|
||||
$errors[] = 'No candidates assigned to this question';
|
||||
} else {
|
||||
$errors[] = 'Missing candidates: '.implode(', ', $missing);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] !== $duplicates) {
|
||||
$errors[] = 'Duplicate candidates: '.implode(', ', $duplicates);
|
||||
}
|
||||
|
||||
return implode('. ', $errors);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,15 @@ class QuizCandidate
|
||||
#[ORM\Column]
|
||||
public float $corrections = 0;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
|
||||
public int $penaltySeconds = 0;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
|
||||
public bool $active = true;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
|
||||
public ?\DateTimeImmutable $started = null;
|
||||
|
||||
#[Gedmo\Timestampable(on: 'create')]
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
|
||||
public private(set) \DateTimeImmutable $created;
|
||||
|
||||
@@ -30,6 +30,7 @@ class Season
|
||||
|
||||
/** @var Collection<int, Quiz> */
|
||||
#[ORM\OneToMany(targetEntity: Quiz::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['id' => 'ASC'])]
|
||||
public private(set) Collection $quizzes;
|
||||
|
||||
/** @var Collection<int, Candidate> */
|
||||
@@ -39,6 +40,7 @@ class Season
|
||||
|
||||
/** @var Collection<int, User> */
|
||||
#[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'seasons')]
|
||||
#[ORM\OrderBy(['email' => 'ASC'])]
|
||||
public private(set) Collection $owners;
|
||||
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
|
||||
@@ -17,12 +17,16 @@ class SettingsForm extends AbstractType
|
||||
{
|
||||
$builder
|
||||
->add('showNumbers', options: [
|
||||
'label' => 'Show Numbers',
|
||||
'label_attr' => ['class' => 'checkbox-switch'],
|
||||
'attr' => ['role' => 'switch', 'switch' => null]])
|
||||
->add('confirmAnswers', options: [
|
||||
'label' => 'Confirm Answers',
|
||||
'label_attr' => ['class' => 'checkbox-switch'],
|
||||
'attr' => ['role' => 'switch', 'switch' => null]])
|
||||
->add('save', SubmitType::class)
|
||||
->add('save', SubmitType::class, [
|
||||
'label' => 'Save',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
@@ -20,14 +21,28 @@ class QuizCandidateRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, QuizCandidate::class);
|
||||
}
|
||||
|
||||
/** @return bool true if a new entry was created */
|
||||
public function createIfNotExist(Quiz $quiz, Candidate $candidate): bool
|
||||
/** @return bool|null true if a new entry was created, false if it already exists, null if candidate is inactive */
|
||||
public function createIfNotExist(Quiz $quiz, Candidate $candidate): ?bool
|
||||
{
|
||||
if (0 !== $this->count(['candidate' => $candidate, 'quiz' => $quiz])) {
|
||||
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
|
||||
|
||||
if (null !== $quizCandidate) {
|
||||
// Check if candidate is inactive
|
||||
if (!$quizCandidate->active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If QuizCandidate exists but hasn't started yet, set the started timestamp
|
||||
if (null === $quizCandidate->started) {
|
||||
$quizCandidate->started = new DateTimeImmutable();
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||
$quizCandidate->started = new DateTimeImmutable();
|
||||
$this->getEntityManager()->persist($quizCandidate);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
@@ -44,4 +59,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
|
||||
$quizCandidate->corrections = $corrections;
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function setPenaltyForCandidate(Quiz $quiz, Candidate $candidate, int $penalty): void
|
||||
{
|
||||
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
|
||||
if (!$quizCandidate instanceof QuizCandidate) {
|
||||
throw new \InvalidArgumentException('Quiz candidate not found');
|
||||
}
|
||||
|
||||
$quizCandidate->penaltySeconds = $penalty;
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Safe\Exceptions\DatetimeException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Tvdt\Dto\Result;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
@@ -81,26 +82,86 @@ class QuizRepository extends ServiceEntityRepository
|
||||
c.name,
|
||||
sum(case when a.isRightAnswer = true then 1 else 0 end) as correct,
|
||||
qd.corrections,
|
||||
qd.penaltySeconds,
|
||||
max(ga.created) as end_time,
|
||||
qd.created as start_time,
|
||||
qd.started as start_time,
|
||||
(sum(case when a.isRightAnswer = true then 1 else 0 end) + qd.corrections) as score
|
||||
from Tvdt\Entity\Candidate c
|
||||
join c.givenAnswers ga
|
||||
join ga.answer a
|
||||
join c.quizData qd
|
||||
where qd.quiz = :quiz and ga.quiz = :quiz
|
||||
where qd.quiz = :quiz and ga.quiz = :quiz and qd.started is not null
|
||||
group by ga.quiz, c.id, qd.id
|
||||
order by score desc, max(ga.created) - qd.created asc
|
||||
order by score desc, max(ga.created) - qd.started asc
|
||||
DQL
|
||||
)->setParameter('quiz', $quiz)->getResult();
|
||||
|
||||
return array_map(static fn (array $row): Result => new Result(
|
||||
id: $row['id'],
|
||||
name: $row['name'],
|
||||
correct: (int) $row['correct'],
|
||||
corrections: $row['corrections'],
|
||||
time: $row['start_time']->diff(new DateTimeImmutable($row['end_time'])),
|
||||
score: $row['score'],
|
||||
), $result);
|
||||
return array_map(static function (array $row): Result {
|
||||
\assert($row['start_time'] instanceof \DateTimeImmutable);
|
||||
|
||||
return new Result(
|
||||
id: $row['id'],
|
||||
name: $row['name'],
|
||||
correct: (int) $row['correct'],
|
||||
corrections: $row['corrections'],
|
||||
penaltySeconds: $row['penaltySeconds'],
|
||||
time: $row['start_time']->diff(new DateTimeImmutable($row['end_time'])),
|
||||
score: $row['score'],
|
||||
);
|
||||
}, $result);
|
||||
}
|
||||
|
||||
public function fetchWithQuestions(Uuid $id): Quiz
|
||||
{
|
||||
return $this->getEntityManager()->createQuery(<<<dql
|
||||
select q, qz, a from Tvdt\Entity\Quiz q
|
||||
join q.questions qz
|
||||
join qz.answers a
|
||||
where q.id = :id
|
||||
dql)->setParameter('id', $id)->getSingleResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quiz with all relations needed for error checking.
|
||||
* This includes: questions, answers, answer candidates, and season candidates.
|
||||
*/
|
||||
public function fetchWithQuestionsAndCandidates(Uuid $id): Quiz
|
||||
{
|
||||
return $this->getEntityManager()->createQuery(<<<dql
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user