mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-05 15:10:16 +02:00
feat: add question bank management, quiz finalization, and related backend/frontend functionality
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
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\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Exception\BankQuestionAlreadyUsedException;
|
||||
use Tvdt\Exception\QuizLockedException;
|
||||
use Tvdt\Form\BankQuestionFormType;
|
||||
use Tvdt\Repository\BankQuestionRepository;
|
||||
use Tvdt\Repository\QuizRepository;
|
||||
use Tvdt\Security\Voter\SeasonVoter;
|
||||
use Tvdt\Service\QuestionBankService;
|
||||
|
||||
#[AsController]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class QuestionBankController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly BankQuestionRepository $bankQuestionRepository,
|
||||
private readonly QuizRepository $quizRepository,
|
||||
private readonly QuestionBankService $questionBankService,
|
||||
) {}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank',
|
||||
name: 'tvdt_backoffice_question_bank',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function index(Season $season, Request $request): Response
|
||||
{
|
||||
$label = null;
|
||||
$labelId = $request->query->getString('label');
|
||||
if ('' !== $labelId && Uuid::isValid($labelId)) {
|
||||
$label = $this->em->getRepository(QuestionLabel::class)->find($labelId);
|
||||
if ($label instanceof QuestionLabel && $label->season !== $season) {
|
||||
$label = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'bankQuestions' => $this->bankQuestionRepository->findBySeason($season, $label),
|
||||
'assignableQuizzes' => $this->quizRepository->findAssignableForSeason($season),
|
||||
'activeLabel' => $label,
|
||||
'activeTab' => 'question-bank',
|
||||
'template' => 'backoffice/season/tab_question_bank.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/new',
|
||||
name: 'tvdt_backoffice_question_bank_new',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function new(Season $season, Request $request): Response
|
||||
{
|
||||
$bankQuestion = new BankQuestion();
|
||||
|
||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAnswerOrdering($bankQuestion);
|
||||
$season->addBankQuestion($bankQuestion);
|
||||
$this->em->persist($bankQuestion);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/question_bank/form.html.twig', [
|
||||
'season' => $season,
|
||||
'form' => $form,
|
||||
'bankQuestion' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/edit',
|
||||
name: 'tvdt_backoffice_question_bank_edit',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
|
||||
priority: 10,
|
||||
)]
|
||||
public function edit(Season $season, BankQuestion $bankQuestion, Request $request): Response
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAnswerOrdering($bankQuestion);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/question_bank/form.html.twig', [
|
||||
'season' => $season,
|
||||
'form' => $form,
|
||||
'bankQuestion' => $bankQuestion,
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('delete_bank_question')]
|
||||
#[IsGranted(SeasonVoter::DELETE, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/delete',
|
||||
name: 'tvdt_backoffice_question_bank_delete',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 10,
|
||||
)]
|
||||
public function delete(Season $season, BankQuestion $bankQuestion): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
$this->em->remove($bankQuestion);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question removed from the question bank'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('assign_bank_question')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/assign',
|
||||
name: 'tvdt_backoffice_question_bank_assign',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 10,
|
||||
)]
|
||||
public function assign(Season $season, BankQuestion $bankQuestion, Request $request): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $bankQuestion->season);
|
||||
|
||||
$quizId = $request->request->getString('quiz');
|
||||
if (!Uuid::isValid($quizId)) {
|
||||
throw new BadRequestHttpException('Invalid quiz');
|
||||
}
|
||||
|
||||
$quiz = $this->em->getRepository(Quiz::class)->find($quizId);
|
||||
if (!$quiz instanceof Quiz || $quiz->season !== $season) {
|
||||
throw new BadRequestHttpException('Invalid quiz');
|
||||
}
|
||||
|
||||
$this->denyAccessUnlessGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, $quiz);
|
||||
|
||||
try {
|
||||
$this->questionBankService->assignToQuiz($bankQuestion, $quiz);
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to quiz %quiz%', ['%quiz%' => $quiz->name]));
|
||||
} catch (QuizLockedException) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
|
||||
} catch (BankQuestionAlreadyUsedException) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('This question has already been used'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('add_question_label')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/labels',
|
||||
name: 'tvdt_backoffice_question_bank_labels',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
methods: ['POST'],
|
||||
priority: 15,
|
||||
)]
|
||||
public function addLabel(Season $season, Request $request): RedirectResponse
|
||||
{
|
||||
$name = mb_trim($request->request->getString('name'));
|
||||
|
||||
if ('' === $name || mb_strlen($name) > 64) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('Invalid label name'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
$exists = $season->questionLabels->exists(static fn (int $key, QuestionLabel $label): bool => $label->name === $name);
|
||||
if (!$exists) {
|
||||
$season->addQuestionLabel(new QuestionLabel($name));
|
||||
$this->em->flush();
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Label added'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('delete_question_label')]
|
||||
#[IsGranted(SeasonVoter::DELETE, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/question-bank/labels/{label}/delete',
|
||||
name: 'tvdt_backoffice_question_bank_label_delete',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'label' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
priority: 15,
|
||||
)]
|
||||
public function deleteLabel(Season $season, QuestionLabel $label): RedirectResponse
|
||||
{
|
||||
$this->assertSameSeason($season, $label->season);
|
||||
|
||||
foreach ($label->bankQuestions as $bankQuestion) {
|
||||
$bankQuestion->removeLabel($label);
|
||||
}
|
||||
|
||||
$this->em->remove($label);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Label removed'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
private function assertSameSeason(Season $season, Season $subjectSeason): void
|
||||
{
|
||||
if ($season !== $subjectSeason) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
}
|
||||
|
||||
private function applyAnswerOrdering(BankQuestion $bankQuestion): void
|
||||
{
|
||||
$ordering = 1;
|
||||
foreach ($bankQuestion->answers as $answer) {
|
||||
$answer->ordering = $ordering++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -22,6 +23,7 @@ use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
use Tvdt\Repository\QuizCandidateRepository;
|
||||
use Tvdt\Repository\QuizRepository;
|
||||
@@ -252,6 +254,12 @@ class QuizController extends AbstractController
|
||||
)]
|
||||
public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse
|
||||
{
|
||||
if ($quiz instanceof Quiz && !$quiz->isFinalized()) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz must be finalized before it can be activated'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
$season->activeQuiz = $quiz;
|
||||
$this->em->flush();
|
||||
|
||||
@@ -274,7 +282,9 @@ class QuizController extends AbstractController
|
||||
{
|
||||
try {
|
||||
$this->quizRepository->clearQuiz($quiz);
|
||||
$this->addFlash('success', $this->translator->trans('Quiz cleared'));
|
||||
$quiz->finalizedAt = null;
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', $this->translator->trans('Quiz cleared and no longer finalized'));
|
||||
} catch (ErrorClearingQuizException) {
|
||||
$this->addFlash('error', $this->translator->trans('Error clearing quiz'));
|
||||
}
|
||||
@@ -282,6 +292,50 @@ class QuizController extends AbstractController
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('finalize_quiz')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/finalize',
|
||||
name: 'tvdt_backoffice_quiz_finalize',
|
||||
requirements: ['quiz' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function finalizeQuiz(Quiz $quiz): RedirectResponse
|
||||
{
|
||||
if ($quiz->questions->isEmpty() || [] !== $quiz->getQuestionErrors()) {
|
||||
$this->addFlash(FlashType::Warning, $this->translator->trans('The quiz cannot be finalized while it has errors'));
|
||||
} elseif (!$quiz->isFinalized()) {
|
||||
$quiz->finalizedAt = new DateTimeImmutable();
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', $this->translator->trans('Quiz finalized'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('unfinalize_quiz')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/unfinalize',
|
||||
name: 'tvdt_backoffice_quiz_unfinalize',
|
||||
requirements: ['quiz' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function unfinalizeQuiz(Quiz $quiz): RedirectResponse
|
||||
{
|
||||
if ($quiz->hasStartedCandidates()) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz has already been filled in and can no longer be altered'));
|
||||
} elseif ($quiz->season->activeQuiz === $quiz) {
|
||||
$this->addFlash(FlashType::Danger, $this->translator->trans('Deactivate the quiz before undoing the finalization'));
|
||||
} else {
|
||||
$quiz->finalizedAt = null;
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', $this->translator->trans('Quiz is no longer finalized'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('delete_quiz')]
|
||||
#[IsGranted(SeasonVoter::DELETE, subject: 'quiz')]
|
||||
#[Route(
|
||||
|
||||
@@ -39,7 +39,39 @@ class SeasonController extends AbstractController
|
||||
name: 'tvdt_backoffice_season',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
)]
|
||||
public function index(Season $season, Request $request): Response
|
||||
public function index(Season $season): Response
|
||||
{
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'activeTab' => 'tests',
|
||||
'template' => 'backoffice/season/tab_tests.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/candidates',
|
||||
name: 'tvdt_backoffice_season_candidates',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function candidatesTab(Season $season): Response
|
||||
{
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'activeTab' => 'candidates',
|
||||
'template' => 'backoffice/season/tab_candidates.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
#[Route(
|
||||
'/backoffice/season/{seasonCode:season}/settings',
|
||||
name: 'tvdt_backoffice_season_settings',
|
||||
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
|
||||
priority: 10,
|
||||
)]
|
||||
public function settingsTab(Season $season, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(SettingsForm::class, $season->settings);
|
||||
|
||||
@@ -47,11 +79,15 @@ class SeasonController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->flush();
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/season.html.twig', [
|
||||
'season' => $season,
|
||||
'form' => $form,
|
||||
'activeTab' => 'settings',
|
||||
'template' => 'backoffice/season/tab_settings.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\DataFixtures;
|
||||
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
final class DevFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return ['dev'];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'admin@tijdvoordetest.nl';
|
||||
$user->password = $this->passwordHasher->hashPassword($user, '12345678');
|
||||
$user->roles = ['ROLE_ADMIN'];
|
||||
|
||||
$manager->persist($user);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,14 @@ namespace Tvdt\DataFixtures;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\BankQuestionUsage;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\SeasonSettings;
|
||||
@@ -18,6 +23,16 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public const string KRTEK_SEASON = 'krtek-seaspm';
|
||||
|
||||
public const string KRTEK_QUIZ_1 = 'krtek-quiz-1';
|
||||
|
||||
public const string KRTEK_QUIZ_2 = 'krtek-quiz-2';
|
||||
|
||||
public const string BANK_QUESTION_REUSABLE = 'bank-question-reusable';
|
||||
|
||||
public const string BANK_QUESTION_USED = 'bank-question-used';
|
||||
|
||||
public const string BANK_QUESTION_UNUSED = 'bank-question-unused';
|
||||
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return ['test', 'dev'];
|
||||
@@ -47,16 +62,64 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
|
||||
$quiz1 = $this->createQuiz1($season);
|
||||
$season->addQuiz($quiz1);
|
||||
$season->activeQuiz = $quiz1;
|
||||
$season->addQuiz($this->createQuiz2($season));
|
||||
|
||||
$quiz1->finalizedAt = new DateTimeImmutable();
|
||||
$quiz2 = $this->createQuiz2($season);
|
||||
$season->addQuiz($quiz2);
|
||||
|
||||
\assert($season->settings instanceof SeasonSettings);
|
||||
|
||||
$season->settings->confirmAnswers = true;
|
||||
$season->settings->showNumbers = true;
|
||||
|
||||
$this->createQuestionBank($season, $quiz2);
|
||||
|
||||
$manager->flush();
|
||||
|
||||
$this->addReference(self::KRTEK_SEASON, $season);
|
||||
$this->addReference(self::KRTEK_QUIZ_1, $quiz1);
|
||||
$this->addReference(self::KRTEK_QUIZ_2, $quiz2);
|
||||
}
|
||||
|
||||
private function createQuestionBank(Season $season, Quiz $usedInQuiz): void
|
||||
{
|
||||
$location = new QuestionLabel('Locatie');
|
||||
$season->addQuestionLabel($location);
|
||||
$finale = new QuestionLabel('Finale');
|
||||
$season->addQuestionLabel($finale);
|
||||
|
||||
$reusable = new BankQuestion();
|
||||
$reusable->question = 'Wie is de Krtek?';
|
||||
$reusable->reusable = true;
|
||||
$reusable->addLabel($finale);
|
||||
$reusable->addAnswer(new BankAnswer('Claudia', true));
|
||||
$reusable->addAnswer(new BankAnswer('Eelco'));
|
||||
$reusable->addAnswer(new BankAnswer('Elise'));
|
||||
|
||||
$season->addBankQuestion($reusable);
|
||||
|
||||
$used = new BankQuestion();
|
||||
$used->question = 'Waar sliep de Krtek?';
|
||||
$used->addLabel($location);
|
||||
$used->addAnswer(new BankAnswer('Boven', true));
|
||||
$used->addAnswer(new BankAnswer('Beneden'));
|
||||
$used->addUsage(new BankQuestionUsage($used, $usedInQuiz));
|
||||
|
||||
$season->addBankQuestion($used);
|
||||
|
||||
$unused = new BankQuestion();
|
||||
$unused->question = 'Wat at de Krtek als ontbijt?';
|
||||
$unused->addLabel($location);
|
||||
$unused->addLabel($finale);
|
||||
$unused->addAnswer(new BankAnswer('Brood', true));
|
||||
$unused->addAnswer(new BankAnswer('Yoghurt'));
|
||||
$unused->addAnswer(new BankAnswer('Niks'));
|
||||
|
||||
$season->addBankQuestion($unused);
|
||||
|
||||
$this->addReference(self::BANK_QUESTION_REUSABLE, $reusable);
|
||||
$this->addReference(self::BANK_QUESTION_USED, $used);
|
||||
$this->addReference(self::BANK_QUESTION_UNUSED, $unused);
|
||||
}
|
||||
|
||||
private function createQuiz1(Season $season): Quiz
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
class BankAnswer implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
|
||||
public int $ordering = 0;
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'answers')]
|
||||
public BankQuestion $bankQuestion;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(length: 255)]
|
||||
public string $text,
|
||||
#[ORM\Column]
|
||||
public bool $isRightAnswer = false,
|
||||
) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use Tvdt\Repository\BankQuestionRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BankQuestionRepository::class)]
|
||||
class BankQuestion implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
public string $question;
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'bankQuestions')]
|
||||
public Season $season;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
public bool $reusable = false;
|
||||
|
||||
/** @var Collection<int, QuestionLabel> */
|
||||
#[ORM\ManyToMany(targetEntity: QuestionLabel::class, inversedBy: 'bankQuestions')]
|
||||
public private(set) Collection $labels;
|
||||
|
||||
/** @var Collection<int, BankAnswer> */
|
||||
#[Assert\Count(min: 2, minMessage: 'A question needs at least two answers')]
|
||||
#[ORM\OneToMany(targetEntity: BankAnswer::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['ordering' => 'ASC'])]
|
||||
public private(set) Collection $answers;
|
||||
|
||||
/** @var Collection<int, BankQuestionUsage> */
|
||||
#[ORM\OneToMany(targetEntity: BankQuestionUsage::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
|
||||
public private(set) Collection $usages;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->labels = new ArrayCollection();
|
||||
$this->answers = new ArrayCollection();
|
||||
$this->usages = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function addAnswer(BankAnswer $answer): static
|
||||
{
|
||||
if (!$this->answers->contains($answer)) {
|
||||
$this->answers->add($answer);
|
||||
$answer->bankQuestion = $this;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAnswer(BankAnswer $answer): static
|
||||
{
|
||||
$this->answers->removeElement($answer);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addLabel(QuestionLabel $label): static
|
||||
{
|
||||
if (!$this->labels->contains($label)) {
|
||||
$this->labels->add($label);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeLabel(QuestionLabel $label): static
|
||||
{
|
||||
$this->labels->removeElement($label);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addUsage(BankQuestionUsage $usage): static
|
||||
{
|
||||
if (!$this->usages->contains($usage)) {
|
||||
$this->usages->add($usage);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isUsed(): bool
|
||||
{
|
||||
return !$this->usages->isEmpty();
|
||||
}
|
||||
|
||||
public function canBeAssigned(): bool
|
||||
{
|
||||
return $this->reusable || !$this->isUsed();
|
||||
}
|
||||
|
||||
public function isUsedInQuiz(Quiz $quiz): bool
|
||||
{
|
||||
return $this->usages->exists(static fn (int $key, BankQuestionUsage $usage): bool => $usage->quiz === $quiz);
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateAnswers(ExecutionContextInterface $context): void
|
||||
{
|
||||
$correctAnswers = $this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count();
|
||||
|
||||
if (1 !== $correctAnswers) {
|
||||
$context->buildViolation('A question must have exactly one correct answer')
|
||||
->atPath('answers')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->question ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Mapping\Annotation as Gedmo;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\UniqueConstraint(fields: ['bankQuestion', 'quiz'])]
|
||||
class BankQuestionUsage
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[Gedmo\Timestampable(on: 'create')]
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)]
|
||||
public private(set) \DateTimeImmutable $created;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'usages')]
|
||||
public private(set) BankQuestion $bankQuestion,
|
||||
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\ManyToOne]
|
||||
public private(set) Quiz $quiz,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Tvdt\Repository\QuestionLabelRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: QuestionLabelRepository::class)]
|
||||
#[ORM\UniqueConstraint(fields: ['name', 'season'])]
|
||||
class QuestionLabel implements \Stringable
|
||||
{
|
||||
#[ORM\Column(type: UuidType::NAME)]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\Id]
|
||||
public private(set) Uuid $id;
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'questionLabels')]
|
||||
public Season $season;
|
||||
|
||||
/** @var Collection<int, BankQuestion> */
|
||||
#[ORM\ManyToMany(targetEntity: BankQuestion::class, mappedBy: 'labels')]
|
||||
public private(set) Collection $bankQuestions;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(length: 64)]
|
||||
public string $name,
|
||||
) {
|
||||
$this->bankQuestions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Tvdt\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -40,6 +41,9 @@ class Quiz
|
||||
#[ORM\Column(nullable: false, options: ['default' => 1])]
|
||||
public int $dropouts = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
|
||||
public ?\DateTimeImmutable $finalizedAt = null;
|
||||
|
||||
/** @var Collection<int, Elimination> */
|
||||
#[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['createdAt' => 'DESC'])]
|
||||
@@ -62,6 +66,29 @@ class Quiz
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFinalized(): bool
|
||||
{
|
||||
return $this->finalizedAt instanceof \DateTimeImmutable;
|
||||
}
|
||||
|
||||
public function hasStartedCandidates(): bool
|
||||
{
|
||||
return $this->candidateData->exists(static fn (int $key, QuizCandidate $quizCandidate): bool => $quizCandidate->started instanceof \DateTimeImmutable);
|
||||
}
|
||||
|
||||
/**
|
||||
* A locked quiz can no longer be altered: it is either explicitly
|
||||
* finalized or a candidate has already started filling it in.
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
if ($this->isFinalized()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hasStartedCandidates();
|
||||
}
|
||||
|
||||
public function addElimination(Elimination $elimination): self
|
||||
{
|
||||
$this->eliminations->add($elimination);
|
||||
|
||||
@@ -51,12 +51,24 @@ class Season
|
||||
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
|
||||
public ?SeasonSettings $settings = null;
|
||||
|
||||
/** @var Collection<int, BankQuestion> */
|
||||
#[ORM\OneToMany(targetEntity: BankQuestion::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['question' => 'ASC'])]
|
||||
public private(set) Collection $bankQuestions;
|
||||
|
||||
/** @var Collection<int, QuestionLabel> */
|
||||
#[ORM\OneToMany(targetEntity: QuestionLabel::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['name' => 'ASC'])]
|
||||
public private(set) Collection $questionLabels;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->settings = new SeasonSettings();
|
||||
$this->quizzes = new ArrayCollection();
|
||||
$this->candidates = new ArrayCollection();
|
||||
$this->owners = new ArrayCollection();
|
||||
$this->bankQuestions = new ArrayCollection();
|
||||
$this->questionLabels = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function addQuiz(Quiz $quiz): static
|
||||
@@ -79,6 +91,26 @@ class Season
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addBankQuestion(BankQuestion $bankQuestion): static
|
||||
{
|
||||
if (!$this->bankQuestions->contains($bankQuestion)) {
|
||||
$this->bankQuestions->add($bankQuestion);
|
||||
$bankQuestion->season = $this;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addQuestionLabel(QuestionLabel $questionLabel): static
|
||||
{
|
||||
if (!$this->questionLabels->contains($questionLabel)) {
|
||||
$this->questionLabels->add($questionLabel);
|
||||
$questionLabel->season = $this;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addOwner(User $owner): static
|
||||
{
|
||||
if (!$this->owners->contains($owner)) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Exception;
|
||||
|
||||
class BankQuestionAlreadyUsedException extends \Exception {}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Exception;
|
||||
|
||||
class QuizLockedException extends \Exception {}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
|
||||
/** @extends AbstractType<BankAnswer> */
|
||||
class BankAnswerFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('text', TextType::class, [
|
||||
'label' => false,
|
||||
'attr' => ['placeholder' => 'Answer', 'maxlength' => 255],
|
||||
])
|
||||
->add('isRightAnswer', CheckboxType::class, [
|
||||
'label' => 'Correct',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BankAnswer::class,
|
||||
'empty_data' => static fn (): BankAnswer => new BankAnswer(''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Form;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Repository\QuestionLabelRepository;
|
||||
|
||||
/** @extends AbstractType<BankQuestion> */
|
||||
class BankQuestionFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
/** @var Season $season */
|
||||
$season = $options['season'];
|
||||
|
||||
$builder
|
||||
->add('question', TextType::class, [
|
||||
'label' => 'Question',
|
||||
'attr' => ['maxlength' => 255],
|
||||
])
|
||||
->add('reusable', CheckboxType::class, [
|
||||
'label' => 'Reusable',
|
||||
'required' => false,
|
||||
'label_attr' => ['class' => 'checkbox-switch'],
|
||||
'attr' => ['role' => 'switch', 'switch' => null],
|
||||
])
|
||||
->add('labels', EntityType::class, [
|
||||
'label' => 'Labels',
|
||||
'class' => QuestionLabel::class,
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
|
||||
->createQueryBuilder('l')
|
||||
->where('l.season = :season')
|
||||
->orderBy('l.name', 'ASC')
|
||||
->setParameter('season', $season),
|
||||
])
|
||||
->add('answers', CollectionType::class, [
|
||||
'label' => 'Answers',
|
||||
'entry_type' => BankAnswerFormType::class,
|
||||
'entry_options' => ['label' => false],
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'by_reference' => false,
|
||||
'prototype' => true,
|
||||
])
|
||||
->add('save', SubmitType::class, [
|
||||
'label' => 'Save',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BankQuestion::class,
|
||||
]);
|
||||
$resolver->setRequired('season');
|
||||
$resolver->setAllowedTypes('season', Season::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Season;
|
||||
|
||||
/** @extends ServiceEntityRepository<BankQuestion> */
|
||||
class BankQuestionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BankQuestion::class);
|
||||
}
|
||||
|
||||
/** @return list<BankQuestion> */
|
||||
public function findBySeason(Season $season, ?QuestionLabel $label = null): array
|
||||
{
|
||||
$queryBuilder = $this->createQueryBuilder('bq')
|
||||
->select('bq', 'ba', 'l', 'u', 'uq')
|
||||
->leftJoin('bq.answers', 'ba')
|
||||
->leftJoin('bq.labels', 'l')
|
||||
->leftJoin('bq.usages', 'u')
|
||||
->leftJoin('u.quiz', 'uq')
|
||||
->where('bq.season = :season')
|
||||
->orderBy('bq.question', 'ASC')
|
||||
->setParameter('season', $season);
|
||||
|
||||
if ($label instanceof QuestionLabel) {
|
||||
$queryBuilder
|
||||
->andWhere(':label member of bq.labels')
|
||||
->setParameter('label', $label);
|
||||
}
|
||||
|
||||
/* @var list<BankQuestion> */
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
|
||||
/** @extends ServiceEntityRepository<QuestionLabel> */
|
||||
class QuestionLabelRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QuestionLabel::class);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use Safe\Exceptions\DatetimeException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Tvdt\Dto\Result;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
|
||||
/** @extends ServiceEntityRepository<Quiz> */
|
||||
@@ -22,6 +23,29 @@ class QuizRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, Quiz::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quizzes of the season that can still receive bank questions:
|
||||
* not finalized and not started by any candidate.
|
||||
*
|
||||
* @return list<Quiz>
|
||||
*/
|
||||
public function findAssignableForSeason(Season $season): array
|
||||
{
|
||||
/* @var list<Quiz> */
|
||||
return $this->getEntityManager()->createQuery(<<<DQL
|
||||
select q from Tvdt\Entity\Quiz q
|
||||
where q.season = :season
|
||||
and q.finalizedAt is null
|
||||
and not exists (
|
||||
select 1 from Tvdt\Entity\QuizCandidate qc
|
||||
where qc.quiz = q and qc.started is not null
|
||||
)
|
||||
order by q.id asc
|
||||
DQL)
|
||||
->setParameter('season', $season)
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @throws ErrorClearingQuizException */
|
||||
public function clearQuiz(Quiz $quiz): void
|
||||
{
|
||||
|
||||
@@ -8,14 +8,16 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/** @extends Voter<string, Season|Elimination|Quiz|Candidate|Answer|Question> */
|
||||
/** @extends Voter<string, Season|Elimination|Quiz|Candidate|Answer|Question|BankQuestion|QuestionLabel> */
|
||||
final class SeasonVoter extends Voter
|
||||
{
|
||||
public const string EDIT = 'SEASON_EDIT';
|
||||
@@ -24,15 +26,19 @@ final class SeasonVoter extends Voter
|
||||
|
||||
public const string DELETE = 'SEASON_DELETE';
|
||||
|
||||
public const string MODIFY_QUIZ_CONTENT = 'QUIZ_MODIFY_CONTENT';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true)
|
||||
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION, self::MODIFY_QUIZ_CONTENT], true)
|
||||
&& (
|
||||
$subject instanceof Answer
|
||||
|| $subject instanceof BankQuestion
|
||||
|| $subject instanceof Candidate
|
||||
|| $subject instanceof Elimination
|
||||
|| $subject instanceof Season
|
||||
|| $subject instanceof Question
|
||||
|| $subject instanceof QuestionLabel
|
||||
|| $subject instanceof Quiz
|
||||
);
|
||||
}
|
||||
@@ -44,19 +50,36 @@ final class SeasonVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$season = match (true) {
|
||||
$subject instanceof Answer => $subject->question->quiz->season,
|
||||
$subject instanceof Elimination,
|
||||
$subject instanceof Question => $subject->quiz->season,
|
||||
$subject instanceof BankQuestion,
|
||||
$subject instanceof Candidate,
|
||||
$subject instanceof QuestionLabel,
|
||||
$subject instanceof Quiz => $subject->season,
|
||||
$subject instanceof Season => $subject,
|
||||
};
|
||||
|
||||
if (self::MODIFY_QUIZ_CONTENT === $attribute) {
|
||||
$quiz = match (true) {
|
||||
$subject instanceof Answer => $subject->question->quiz,
|
||||
$subject instanceof Question => $subject->quiz,
|
||||
$subject instanceof Quiz => $subject,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$quiz instanceof Quiz || $quiz->isLocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin || $season->isOwner($user);
|
||||
}
|
||||
|
||||
if ($user->isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return match ($attribute) {
|
||||
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
|
||||
default => false,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\BankQuestionUsage;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Exception\BankQuestionAlreadyUsedException;
|
||||
use Tvdt\Exception\QuizLockedException;
|
||||
|
||||
final readonly class QuestionBankService
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $entityManager) {}
|
||||
|
||||
/**
|
||||
* Copy a bank question (with its answers) into a quiz and record the usage.
|
||||
*
|
||||
* @throws QuizLockedException when the quiz is finalized or already filled in
|
||||
* @throws BankQuestionAlreadyUsedException when the question is single-use and used, or already in this quiz
|
||||
*/
|
||||
public function assignToQuiz(BankQuestion $bankQuestion, Quiz $quiz): void
|
||||
{
|
||||
if ($bankQuestion->season !== $quiz->season) {
|
||||
throw new \InvalidArgumentException('Bank question and quiz belong to different seasons');
|
||||
}
|
||||
|
||||
if ($quiz->isLocked()) {
|
||||
throw new QuizLockedException();
|
||||
}
|
||||
|
||||
if (!$bankQuestion->canBeAssigned() || $bankQuestion->isUsedInQuiz($quiz)) {
|
||||
throw new BankQuestionAlreadyUsedException();
|
||||
}
|
||||
|
||||
$maxOrdering = 0;
|
||||
foreach ($quiz->questions as $existingQuestion) {
|
||||
$maxOrdering = max($maxOrdering, $existingQuestion->ordering);
|
||||
}
|
||||
|
||||
$question = new Question();
|
||||
$question->question = $bankQuestion->question;
|
||||
$question->ordering = $maxOrdering + 1;
|
||||
|
||||
foreach ($bankQuestion->answers as $bankAnswer) {
|
||||
$answer = new Answer($bankAnswer->text, $bankAnswer->isRightAnswer);
|
||||
$answer->ordering = $bankAnswer->ordering;
|
||||
$question->addAnswer($answer);
|
||||
}
|
||||
|
||||
$quiz->addQuestion($question);
|
||||
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz));
|
||||
|
||||
$this->entityManager->persist($question);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user