2 Commits

Author SHA1 Message Date
6a77df402d Add quiz clearing and deletion functionality with UI enhancements
This commit introduces the ability to clear quiz results and delete quizzes directly from the backoffice. It includes new routes, controllers, modals for user confirmation, and updates to translations. The `QuizRepository` now supports dedicated methods for clearing results and deleting quizzes along with error handling. Related database migrations and front-end adjustments are also included.
2025-06-07 20:59:01 +02:00
79236d84e9 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.
2025-06-07 16:09:13 +02:00
25 changed files with 505 additions and 92 deletions

View File

@@ -50,7 +50,6 @@ jobs:
- name: Run migrations - name: Run migrations
run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction
- name: Run PHPUnit - name: Run PHPUnit
if: false # Remove this line when the tests are ready
run: docker compose exec -T php vendor/bin/phpunit run: docker compose exec -T php vendor/bin/phpunit
- name: Doctrine Schema Validator - name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate run: docker compose exec -T php bin/console -e test doctrine:schema:validate

View File

@@ -1,5 +1,5 @@
import * as bootstrap from 'bootstrap'
import './bootstrap.js'; import './bootstrap.js';
import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/css/bootstrap.min.css'
import * as bootstrap from 'bootstrap'
import './styles/backoffice.scss'; import './styles/backoffice.scss';

View File

@@ -0,0 +1,17 @@
import {Controller} from '@hotwired/stimulus';
import * as bootstrap from 'bootstrap'
export default class extends Controller {
connect() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
}
clearQuiz() {
new bootstrap.Modal('#clearQuizModal').show();
}
deleteQuiz() {
new bootstrap.Modal('#deleteQuizModal').show();
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250607154730 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE season DROP CONSTRAINT FK_F0E45BA96706D6B
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE season ADD CONSTRAINT FK_F0E45BA96706D6B FOREIGN KEY (active_quiz_id) REFERENCES quiz (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE season DROP CONSTRAINT fk_f0e45ba96706d6b
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE season ADD CONSTRAINT fk_f0e45ba96706d6b FOREIGN KEY (active_quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250607184525 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE elimination DROP CONSTRAINT FK_5947284F853CD175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination ADD CONSTRAINT FK_5947284F853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE given_answer DROP CONSTRAINT FK_9AC61A30853CD175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE given_answer ADD CONSTRAINT FK_9AC61A30853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE given_answer DROP CONSTRAINT fk_9ac61a30853cd175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE given_answer ADD CONSTRAINT fk_9ac61a30853cd175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination DROP CONSTRAINT fk_5947284f853cd175
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE elimination ADD CONSTRAINT fk_5947284f853cd175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
}

View File

@@ -7,3 +7,4 @@ parameters:
- public/ - public/
- src/ - src/
- tests/ - tests/
treatPhpDocTypesAsCertain: false

View File

@@ -27,6 +27,7 @@ return RectorConfig::configure()
phpunitCodeQuality: true, phpunitCodeQuality: true,
doctrineCodeQuality: true, doctrineCodeQuality: true,
symfonyCodeQuality: true, symfonyCodeQuality: true,
// naming: true
) )
->withAttributesSets(all: true) ->withAttributesSets(all: true)
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true) ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)

View File

@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBase
abstract class AbstractController extends AbstractBaseController abstract class AbstractController extends AbstractBaseController
{ {
protected const string SEASON_CODE_REGEX = '[A-Za-z\d]{5}'; protected const string SEASON_CODE_REGEX = '[A-Za-z\d]{5}';
protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+'; protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+';
#[\Override] #[\Override]

View File

@@ -39,9 +39,10 @@ final class PrepareEliminationController extends AbstractController
$elimination->updateFromInputBag($request->request); $elimination->updateFromInputBag($request->request);
$em->flush(); $em->flush();
if (true === $request->request->getBoolean('start')) { if ($request->request->getBoolean('start')) {
return $this->redirectToRoute('app_elimination', ['elimination' => $elimination->getId()]); return $this->redirectToRoute('app_elimination', ['elimination' => $elimination->getId()]);
} }
$this->addFlash('success', 'Elimination updated'); $this->addFlash('success', 'Elimination updated');
} }

View File

@@ -5,15 +5,23 @@ declare(strict_types=1);
namespace App\Controller\Backoffice; namespace App\Controller\Backoffice;
use App\Controller\AbstractController; use App\Controller\AbstractController;
use App\Entity\Candidate;
use App\Entity\Quiz; use App\Entity\Quiz;
use App\Entity\Season; use App\Entity\Season;
use App\Exception\ErrorClearingQuizException;
use App\Repository\CandidateRepository; use App\Repository\CandidateRepository;
use App\Repository\QuizCandidateRepository;
use App\Repository\QuizRepository;
use App\Security\Voter\SeasonVoter; use App\Security\Voter\SeasonVoter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
@@ -21,9 +29,12 @@ class QuizController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly CandidateRepository $candidateRepository, private readonly CandidateRepository $candidateRepository,
private readonly TranslatorInterface $translator,
) {} ) {}
#[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], requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
)] )]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -36,11 +47,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], requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
)] )]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[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); $season->setActiveQuiz($quiz);
$em->flush(); $em->flush();
@@ -51,4 +64,53 @@ class QuizController extends AbstractController
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]); return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
} }
#[Route(
'/backoffice/quiz/{quiz}/clear',
name: 'app_backoffice_quiz_clear',
)]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
public function clearQuiz(Quiz $quiz, QuizRepository $quizRepository): RedirectResponse
{
try {
$quizRepository->clearQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz cleared'));
} catch (ErrorClearingQuizException) {
$this->addFlash('error', $this->translator->trans('Error clearing quiz'));
}
return $this->redirectToRoute('app_backoffice_quiz', ['seasonCode' => $quiz->getSeason()->getSeasonCode(), 'quiz' => $quiz->getId()]);
}
#[Route(
'/backoffice/quiz/{quiz}/delete',
name: 'app_backoffice_quiz_delete',
)]
#[IsGranted(SeasonVoter::DELETE, subject: 'quiz')]
public function deleteQuiz(Quiz $quiz, QuizRepository $quizRepository): RedirectResponse
{
$quizRepository->deleteQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz deleted'));
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $quiz->getSeason()->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

@@ -26,7 +26,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
class SeasonController extends AbstractController class SeasonController extends AbstractController
{ {
public function __construct(private readonly TranslatorInterface $translator, private EntityManagerInterface $em, public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $em,
) {} ) {}
#[Route( #[Route(

View File

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

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Uid\Uuid;
class Elimination class Elimination
{ {
public const string SCREEN_GREEN = 'green'; public const string SCREEN_GREEN = 'green';
public const string SCREEN_RED = 'red'; public const string SCREEN_RED = 'red';
#[ORM\Id] #[ORM\Id]
@@ -35,7 +36,7 @@ class Elimination
public function __construct( public function __construct(
#[ORM\ManyToOne(inversedBy: 'eliminations')] #[ORM\ManyToOne(inversedBy: 'eliminations')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Quiz $quiz, private Quiz $quiz,
) {} ) {}
@@ -66,7 +67,7 @@ class Elimination
/** @param InputBag<bool|float|int|string> $inputBag */ /** @param InputBag<bool|float|int|string> $inputBag */
public function updateFromInputBag(InputBag $inputBag): self public function updateFromInputBag(InputBag $inputBag): self
{ {
foreach ($this->data as $name => $screenColour) { foreach (array_keys($this->data) as $name) {
$newColour = $inputBag->get('colour-'.mb_strtolower($name)); $newColour = $inputBag->get('colour-'.mb_strtolower($name));
if (\is_string($newColour)) { if (\is_string($newColour)) {
$this->data[$name] = $inputBag->get('colour-'.mb_strtolower($name)); $this->data[$name] = $inputBag->get('colour-'.mb_strtolower($name));

View File

@@ -31,7 +31,7 @@ class GivenAnswer
private Candidate $candidate, private Candidate $candidate,
#[ORM\ManyToOne] #[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Quiz $quiz, private Quiz $quiz,
#[ORM\ManyToOne(inversedBy: 'givenAnswers')] #[ORM\ManyToOne(inversedBy: 'givenAnswers')]

View File

@@ -43,6 +43,7 @@ class Season
private Collection $owners; private Collection $owners;
#[ORM\ManyToOne] #[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Quiz $ActiveQuiz = null; private ?Quiz $ActiveQuiz = null;
public function __construct() public function __construct()

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exception;
class ErrorClearingQuizException extends \Exception {}

View File

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

View File

@@ -33,4 +33,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
return true; 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,17 +4,59 @@ declare(strict_types=1);
namespace App\Repository; namespace App\Repository;
use App\Entity\Elimination;
use App\Entity\GivenAnswer;
use App\Entity\Quiz; use App\Entity\Quiz;
use App\Entity\QuizCandidate;
use App\Exception\ErrorClearingQuizException;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
/** /**
* @extends ServiceEntityRepository<Quiz> * @extends ServiceEntityRepository<Quiz>
*/ */
class QuizRepository extends ServiceEntityRepository class QuizRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
{ {
parent::__construct($registry, Quiz::class); parent::__construct($registry, Quiz::class);
} }
/** @throws ErrorClearingQuizException */
public function clearQuiz(Quiz $quiz): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->createQueryBuilder()
->delete()->from(QuizCandidate::class, 'qc')
->where('qc.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
$em->createQueryBuilder()
->delete()->from(GivenAnswer::class, 'ga')
->where('ga.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
$em->createQueryBuilder()
->delete()->from(Elimination::class, 'e')
->where('e.quiz = :quiz')
->setParameter('quiz', $quiz)
->getQuery()->execute();
} catch (\Throwable $throwable) {
$this->logger->error($throwable->getMessage());
$em->rollback();
throw new ErrorClearingQuizException(previous: $throwable);
}
$em->commit();
}
public function deleteQuiz(Quiz $quiz): void
{
$this->getEntityManager()->remove($quiz);
$this->getEntityManager()->flush();
}
} }

View File

@@ -4,7 +4,11 @@ declare(strict_types=1);
namespace App\Security\Voter; namespace App\Security\Voter;
use App\Entity\Answer;
use App\Entity\Candidate;
use App\Entity\Elimination; use App\Entity\Elimination;
use App\Entity\Question;
use App\Entity\Quiz;
use App\Entity\Season; use App\Entity\Season;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; 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 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], 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 protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{ {
$user = $token->getUser(); $user = $token->getUser();
@@ -37,7 +48,24 @@ final class SeasonVoter extends Voter
return true; 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) { return match ($attribute) {
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user), self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),

View File

@@ -11,43 +11,45 @@
{{ 'Add'|trans }} {{ 'Add'|trans }}
</a> </a>
</div> </div>
<table class="table table-hover"> {% if seasons %}
<thead> <table class="table table-hover">
<tr> <thead>
{% if is_granted('ROLE_ADMIN') %} <tr>
<th scope="col">{{ 'Owner(s)'|trans }}</th>
{% endif %}
<th scope="col">{{ 'Name'|trans }}</th>
<th scope="col">{{ 'Active Quiz'|trans }}</th>
<th scope="col">{{ 'Season Code'|trans }}</th>
<th scope="col">{{ 'Manage'|trans }}</th>
</tr>
</thead>
<tbody>
{% for season in seasons %}
<tr class="align-middle">
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_ADMIN') %}
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td> <th scope="col">{{ 'Owner(s)'|trans }}</th>
{% endif %} {% endif %}
<td>{{ season.name }}</td> <th scope="col">{{ 'Name'|trans }}</th>
<td> <th scope="col">{{ 'Active Quiz'|trans }}</th>
{% if season.activeQuiz %} <th scope="col">{{ 'Season Code'|trans }}</th>
{{ season.activeQuiz.name }} <th scope="col">{{ 'Manage'|trans }}</th>
{% else %}
{{ 'No active quiz'|trans }}
{% endif %}
</td>
<td>
<a {% if season.activeQuiz %}href="{{ path('app_quiz_enter_name', {seasonCode: season.seasonCode}) }}"
{% else %}class="disabled" {% endif %}>{{ season.seasonCode }}</a>
</td>
<td>
<a href="{{ path('app_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ 'Manage'|trans }}</a>
</td>
</tr> </tr>
{% else %} </thead>
EMPTY <tbody>
{% endfor %} {% for season in seasons %}
</tbody> <tr class="align-middle">
</table> {% if is_granted('ROLE_ADMIN') %}
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td>
{% endif %}
<td>{{ season.name }}</td>
<td>
{% if season.activeQuiz %}
{{ season.activeQuiz.name }}
{% else %}
{{ 'No active quiz'|trans }}
{% endif %}
</td>
<td>
<a {% if season.activeQuiz %}href="{{ path('app_quiz_enter_name', {seasonCode: season.seasonCode}) }}"
{% else %}class="disabled" {% endif %}>{{ season.seasonCode }}</a>
</td>
<td>
<a href="{{ path('app_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ 'Manage'|trans }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{{ 'You have no seasons yet.'|trans }}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -2,13 +2,19 @@
{% block body %} {% block body %}
<h2 class="py-2">{{ 'Quiz'|trans }}: {{ quiz.season.name }} - {{ quiz.name }}</h2> <h2 class="py-2">{{ 'Quiz'|trans }}: {{ quiz.season.name }} - {{ quiz.name }}</h2>
<div class="py-2 btn-group"> <div class="py-2 btn-group" data-controller="bo--quiz">
<a class="btn btn-primary {% if quiz is same as(season.activeQuiz) %}disabled{% endif %}" <a class="btn btn-primary {% if quiz is same as(season.activeQuiz) %}disabled{% endif %}"
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ 'Make active'|trans }}</a> href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ 'Make active'|trans }}</a>
{% if quiz is same as (season.activeQuiz) %} {% if quiz is same as (season.activeQuiz) %}
<a class="btn btn-secondary" <a class="btn btn-secondary"
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}">{{ 'Deactivate Quiz'|trans }}</a> href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}">{{ 'Deactivate Quiz'|trans }}</a>
{% endif %} {% endif %}
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
{{ 'Clear quiz...'|trans }}
</button>
<button class="btn btn-danger" data-action="click->bo--quiz#deleteQuiz">
{{ 'Delete Quiz...'|trans }}
</button>
</div> </div>
<div id="questions"> <div id="questions">
@@ -74,10 +80,10 @@
<thead> <thead>
<tr> <tr>
<th scope="col">{{ 'Candidate'|trans }}</th> <th scope="col">{{ 'Candidate'|trans }}</th>
<th scope="col">{{ 'Correct Answers'|trans }}</th> <th style="width: 15%" scope="col">{{ 'Correct Answers'|trans }}</th>
<th scope="col">{{ 'Corrections'|trans }}</th> <th style="width: 20%" scope="col">{{ 'Corrections'|trans }}</th>
<th scope="col">{{ 'Score'|trans }}</th> <th style="width: 10%" scope="col">{{ 'Score'|trans }}</th>
<th scope="col">{{ 'Time'|trans }}</th> <th style="width: 20%" scope="col">{{ 'Time'|trans }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -85,7 +91,21 @@
<tr class="table-{% if loop.revindex > quiz.dropouts %}success{% else %}danger{% endif %}"> <tr class="table-{% if loop.revindex > quiz.dropouts %}success{% else %}danger{% endif %}">
<td>{{ candidate.name }}</td> <td>{{ candidate.name }}</td>
<td>{{ candidate.correct|default('0') }}</td> <td>{{ candidate.correct|default('0') }}</td>
<td>{{ candidate.corrections|default('0') }}</td> <td>
<form method="post"
action="{{ path('app_backoffice_modify_correction', {quiz: quiz.id, candidate: candidate.id}) }}">
<div class="row">
<div class="col-8">
<input class="form-control form-control-sm" type="number"
value="{{ candidate.corrections }}" step="0.5"
name="corrections">
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
</div>
</div>
</form>
</td>
<td>{{ candidate.score|default('x') }}</td> <td>{{ candidate.score|default('x') }}</td>
<td>{{ candidate.time }}</td> <td>{{ candidate.time }}</td>
</tr> </tr>
@@ -97,16 +117,48 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
});
</script>
{% endblock javascripts %}
{% block title %}
{# Modal Clear #}
<div class="modal fade" id="clearQuizModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">Please Confirm</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to clear all the results? This will also delete al the eliminations.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">No</button>
<a href="{{ path('app_backoffice_quiz_clear', {quiz: quiz.id}) }}" class="btn btn-danger">Yes</a>
</div>
</div>
</div>
</div>
{# Modal Delete #}
<div class="modal fade" id="deleteQuizModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">Please Confirm</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this quiz?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">No</button>
<a href="{{ path('app_backoffice_quiz_delete', {quiz: quiz.id}) }}" class="btn btn-danger">Yes</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block title %}
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,7 @@
class="elimination-screen" id="{{ colour }}" class="elimination-screen" id="{{ colour }}"
alt="Screen with colour {{ colour }}" alt="Screen with colour {{ colour }}"
data-controller="elimination" data-controller="elimination"
data-action="click->elimination#next" data-action="click->elimination#next keydown@document->elimination#next"
tabindex="0" tabindex="0"
> >

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Tests\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 App\Security\Voter\SeasonVoter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
final class SeasonVoterTest extends TestCase
{
private SeasonVoter $seasonVoter;
private TokenInterface&Stub $token;
protected function setUp(): void
{
$this->seasonVoter = new SeasonVoter();
$this->token = $this->createStub(TokenInterface::class);
$user = $this->createStub(User::class);
$this->token->method('getUser')->willReturn($user);
}
#[DataProvider('typesProvider')]
public function testWithTypes(mixed $subject): void
{
$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->seasonVoter->vote($this->token, $subject, ['SEASON_EDIT']));
}
public function testNotOwnerWillReturnDenied(): void
{
$season = self::createStub(Season::class);
$season->method('isOwner')->willReturn(false);
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($this->token, $season, ['SEASON_EDIT']));
}
public static function typesProvider(): \Generator
{
$season = self::createStub(Season::class);
$season->method('isOwner')->willReturn(true);
$quiz = self::createStub(Quiz::class);
$quiz->method('getSeason')->willReturn($season);
$elimination = self::createStub(Elimination::class);
$elimination->method('getQuiz')->willReturn($quiz);
$candidate = self::createStub(Candidate::class);
$candidate->method('getSeason')->willReturn($season);
$question = self::createStub(Question::class);
$question->method('getQuiz')->willReturn($quiz);
$answer = self::createStub(Answer::class);
$answer->method('getQuestion')->willReturn($question);
yield 'Season' => [$season];
yield 'Elimination' => [$elimination];
yield 'Quiz' => [$quiz];
yield 'Candidate' => [$candidate];
yield 'Question' => [$question];
yield 'Answer' => [$answer];
}
}

View File

@@ -49,6 +49,10 @@
<source>Candidates</source> <source>Candidates</source>
<target>Kandidaten</target> <target>Kandidaten</target>
</trans-unit> </trans-unit>
<trans-unit id="FNY513f" resname="Clear quiz...">
<source>Clear quiz...</source>
<target>Test leegmaken...</target>
</trans-unit>
<trans-unit id="sFpB4C2" resname="Correct Answers"> <trans-unit id="sFpB4C2" resname="Correct Answers">
<source>Correct Answers</source> <source>Correct Answers</source>
<target>Goede antwoorden</target> <target>Goede antwoorden</target>
@@ -77,6 +81,10 @@
<source>Deactivate Quiz</source> <source>Deactivate Quiz</source>
<target>Deactiveer test</target> <target>Deactiveer test</target>
</trans-unit> </trans-unit>
<trans-unit id="p9GNNI3" resname="Delete Quiz...">
<source>Delete Quiz...</source>
<target>Test verwijderen...</target>
</trans-unit>
<trans-unit id="R9yHzHv" resname="Download Template"> <trans-unit id="R9yHzHv" resname="Download Template">
<source>Download Template</source> <source>Download Template</source>
<target>Download sjabloon</target> <target>Download sjabloon</target>
@@ -93,6 +101,10 @@
<source>Enter your name</source> <source>Enter your name</source>
<target>Voor je naam in</target> <target>Voor je naam in</target>
</trans-unit> </trans-unit>
<trans-unit id="HNMwvRn" resname="Error clearing quiz">
<source>Error clearing quiz</source>
<target>Fout bij leegmaken test</target>
</trans-unit>
<trans-unit id="OGiIhMH" resname="Green"> <trans-unit id="OGiIhMH" resname="Green">
<source>Green</source> <source>Green</source>
<target>Groen</target> <target>Groen</target>
@@ -177,10 +189,18 @@
<source>Quiz Added!</source> <source>Quiz Added!</source>
<target>Test toegevoegd!</target> <target>Test toegevoegd!</target>
</trans-unit> </trans-unit>
<trans-unit id="vXN8b2w" resname="Quiz cleared">
<source>Quiz cleared</source>
<target>Test leeggemaakt</target>
</trans-unit>
<trans-unit id="LbVe.2c" resname="Quiz completed"> <trans-unit id="LbVe.2c" resname="Quiz completed">
<source>Quiz completed</source> <source>Quiz completed</source>
<target>Test voltooid</target> <target>Test voltooid</target>
</trans-unit> </trans-unit>
<trans-unit id="XdfTTMD" resname="Quiz deleted">
<source>Quiz deleted</source>
<target>Test verwijderd</target>
</trans-unit>
<trans-unit id="frxoIkW" resname="Quiz name"> <trans-unit id="frxoIkW" resname="Quiz name">
<source>Quiz name</source> <source>Quiz name</source>
<target>Testnaam</target> <target>Testnaam</target>
@@ -237,10 +257,6 @@
<source>Sign in</source> <source>Sign in</source>
<target>Log in</target> <target>Log in</target>
</trans-unit> </trans-unit>
<trans-unit id="2QO7aYC" resname="Start Elimination">
<source>Start Elimination</source>
<target>Start eliminatie</target>
</trans-unit>
<trans-unit id="9m8DOBg" resname="Submit"> <trans-unit id="9m8DOBg" resname="Submit">
<source>Submit</source> <source>Submit</source>
<target>Verstuur</target> <target>Verstuur</target>
@@ -253,10 +269,18 @@
<source>There are no answers for this question</source> <source>There are no answers for this question</source>
<target>Er zijn geen antwoorden voor deze vraag</target> <target>Er zijn geen antwoorden voor deze vraag</target>
</trans-unit> </trans-unit>
<trans-unit id=".LrcTyU" resname="There is no active quiz">
<source>There is no active quiz</source>
<target>Er is geen test actief</target>
</trans-unit>
<trans-unit id="Dptvysv" resname="Time"> <trans-unit id="Dptvysv" resname="Time">
<source>Time</source> <source>Time</source>
<target>Tijd</target> <target>Tijd</target>
</trans-unit> </trans-unit>
<trans-unit id="0afY1NF" resname="You have no seasons yet.">
<source>You have no seasons yet.</source>
<target>Je hebt nog geen seizoenen.</target>
</trans-unit>
<trans-unit id="vVQAP9A" resname="Your Seasons"> <trans-unit id="vVQAP9A" resname="Your Seasons">
<source>Your Seasons</source> <source>Your Seasons</source>
<target>Jouw seizoenen</target> <target>Jouw seizoenen</target>