mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-03-07 05:04:20 +01:00
Compare commits
2 Commits
beb8d13dde
...
6a77df402d
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a77df402d | |||
| 79236d84e9 |
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -50,7 +50,6 @@ jobs:
|
||||
- name: Run migrations
|
||||
run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction
|
||||
- name: Run PHPUnit
|
||||
if: false # Remove this line when the tests are ready
|
||||
run: docker compose exec -T php vendor/bin/phpunit
|
||||
- name: Doctrine Schema Validator
|
||||
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as bootstrap from 'bootstrap'
|
||||
import './bootstrap.js';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import * as bootstrap from 'bootstrap'
|
||||
|
||||
import './styles/backoffice.scss';
|
||||
|
||||
17
assets/controllers/bo/quiz_controller.js
Normal file
17
assets/controllers/bo/quiz_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
41
migrations/Version20250607154730.php
Normal file
41
migrations/Version20250607154730.php
Normal 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);
|
||||
}
|
||||
}
|
||||
53
migrations/Version20250607184525.php
Normal file
53
migrations/Version20250607184525.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ parameters:
|
||||
- public/
|
||||
- src/
|
||||
- tests/
|
||||
treatPhpDocTypesAsCertain: false
|
||||
|
||||
@@ -27,6 +27,7 @@ return RectorConfig::configure()
|
||||
phpunitCodeQuality: true,
|
||||
doctrineCodeQuality: true,
|
||||
symfonyCodeQuality: true,
|
||||
// naming: true
|
||||
)
|
||||
->withAttributesSets(all: true)
|
||||
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
|
||||
|
||||
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBase
|
||||
abstract class AbstractController extends AbstractBaseController
|
||||
{
|
||||
protected const string SEASON_CODE_REGEX = '[A-Za-z\d]{5}';
|
||||
|
||||
protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+';
|
||||
|
||||
#[\Override]
|
||||
|
||||
@@ -39,9 +39,10 @@ final class PrepareEliminationController extends AbstractController
|
||||
$elimination->updateFromInputBag($request->request);
|
||||
$em->flush();
|
||||
|
||||
if (true === $request->request->getBoolean('start')) {
|
||||
if ($request->request->getBoolean('start')) {
|
||||
return $this->redirectToRoute('app_elimination', ['elimination' => $elimination->getId()]);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Elimination updated');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,23 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Backoffice;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Entity\Candidate;
|
||||
use App\Entity\Quiz;
|
||||
use App\Entity\Season;
|
||||
use App\Exception\ErrorClearingQuizException;
|
||||
use App\Repository\CandidateRepository;
|
||||
use App\Repository\QuizCandidateRepository;
|
||||
use App\Repository\QuizRepository;
|
||||
use App\Security\Voter\SeasonVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
#[AsController]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
@@ -21,9 +29,12 @@ class QuizController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
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],
|
||||
)]
|
||||
#[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],
|
||||
)]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): Response
|
||||
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): RedirectResponse
|
||||
{
|
||||
$season->setActiveQuiz($quiz);
|
||||
$em->flush();
|
||||
@@ -51,4 +64,53 @@ class QuizController extends AbstractController
|
||||
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
#[IsGranted('ROLE_USER')]
|
||||
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(
|
||||
|
||||
@@ -21,10 +21,10 @@ use App\Repository\QuestionRepository;
|
||||
use App\Repository\QuizCandidateRepository;
|
||||
use App\Repository\SeasonRepository;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
@@ -109,7 +109,7 @@ final class QuizController extends AbstractController
|
||||
$answer = $answerRepository->findOneBy(['id' => $request->request->get('answer')]);
|
||||
|
||||
if (!$answer instanceof Answer) {
|
||||
throw new BadRequestException('Invalid Answer ID');
|
||||
throw new BadRequestHttpException('Invalid Answer ID');
|
||||
}
|
||||
|
||||
$givenAnswer = new GivenAnswer($candidate, $answer->getQuestion()->getQuiz(), $answer);
|
||||
|
||||
@@ -18,6 +18,7 @@ use Symfony\Component\Uid\Uuid;
|
||||
class Elimination
|
||||
{
|
||||
public const string SCREEN_GREEN = 'green';
|
||||
|
||||
public const string SCREEN_RED = 'red';
|
||||
|
||||
#[ORM\Id]
|
||||
@@ -35,7 +36,7 @@ class Elimination
|
||||
|
||||
public function __construct(
|
||||
#[ORM\ManyToOne(inversedBy: 'eliminations')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Quiz $quiz,
|
||||
) {}
|
||||
|
||||
@@ -66,7 +67,7 @@ class Elimination
|
||||
/** @param InputBag<bool|float|int|string> $inputBag */
|
||||
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));
|
||||
if (\is_string($newColour)) {
|
||||
$this->data[$name] = $inputBag->get('colour-'.mb_strtolower($name));
|
||||
|
||||
@@ -31,7 +31,7 @@ class GivenAnswer
|
||||
private Candidate $candidate,
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Quiz $quiz,
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]
|
||||
|
||||
@@ -43,6 +43,7 @@ class Season
|
||||
private Collection $owners;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Quiz $ActiveQuiz = null;
|
||||
|
||||
public function __construct()
|
||||
|
||||
7
src/Exception/ErrorClearingQuizException.php
Normal file
7
src/Exception/ErrorClearingQuizException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
class ErrorClearingQuizException extends \Exception {}
|
||||
@@ -16,7 +16,7 @@ use Symfony\Component\Uid\Uuid;
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Candidate>
|
||||
*
|
||||
* @phpstan-type Result array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float, score: float}
|
||||
* @phpstan-type Result array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections: float, score: float}
|
||||
* @phpstan-type ResultList list<Result>
|
||||
*/
|
||||
class CandidateRepository extends ServiceEntityRepository
|
||||
@@ -54,40 +54,32 @@ class CandidateRepository extends ServiceEntityRepository
|
||||
/** @return ResultList */
|
||||
public function getScores(Quiz $quiz): array
|
||||
{
|
||||
$scoreQb = $this->createQueryBuilder('c', 'c.id')
|
||||
->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct')
|
||||
$qb = $this->createQueryBuilder('c', 'c.id')
|
||||
->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'qc.corrections', 'max(ga.created) - qc.created as time')
|
||||
->join('c.givenAnswers', 'ga')
|
||||
->join('ga.answer', 'a')
|
||||
->where('ga.quiz = :quiz')
|
||||
->groupBy('c.id')
|
||||
->setParameter('quiz', $quiz);
|
||||
|
||||
$startTimeCorrectionQb = $this->createQueryBuilder('c', 'c.id')
|
||||
->select('c.id', 'qc.corrections', 'max(ga.created) - qc.created as time')
|
||||
->join('c.quizData', 'qc')
|
||||
->join('c.givenAnswers', 'ga')
|
||||
->where('qc.quiz = :quiz')
|
||||
->groupBy('ga.quiz', 'c.id', 'qc.id')
|
||||
->setParameter('quiz', $quiz);
|
||||
|
||||
$merged = array_merge_recursive(
|
||||
$scoreQb->getQuery()->getArrayResult(),
|
||||
$startTimeCorrectionQb->getQuery()->getArrayResult(),
|
||||
return $this->sortResults(
|
||||
$this->calculateScore(
|
||||
$qb->getQuery()->getResult(),
|
||||
),
|
||||
);
|
||||
|
||||
return $this->sortResults($this->calculateScore($merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float}> $in
|
||||
* @param array<string, array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections: float}> $in
|
||||
*
|
||||
* @return array<string, Result>
|
||||
* */
|
||||
*/
|
||||
private function calculateScore(array $in): array
|
||||
{
|
||||
return array_map(static fn ($candidate): array => [
|
||||
...$candidate,
|
||||
'score' => $candidate['correct'] + ($candidate['corrections'] ?? 0.0),
|
||||
'score' => $candidate['correct'] + $candidate['corrections'],
|
||||
], $in);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,4 +33,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function setCorrectionsForCandidate(Quiz $quiz, Candidate $candidate, float $corrections): void
|
||||
{
|
||||
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
|
||||
if (!$quizCandidate instanceof QuizCandidate) {
|
||||
throw new \InvalidArgumentException('Quiz candidate not found');
|
||||
}
|
||||
|
||||
$quizCandidate->setCorrections($corrections);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,59 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Elimination;
|
||||
use App\Entity\GivenAnswer;
|
||||
use App\Entity\Quiz;
|
||||
use App\Entity\QuizCandidate;
|
||||
use App\Exception\ErrorClearingQuizException;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Quiz>
|
||||
*/
|
||||
class QuizRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Security\Voter;
|
||||
|
||||
use App\Entity\Answer;
|
||||
use App\Entity\Candidate;
|
||||
use App\Entity\Elimination;
|
||||
use App\Entity\Question;
|
||||
use App\Entity\Quiz;
|
||||
use App\Entity\Season;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
@@ -22,10 +26,17 @@ final class SeasonVoter extends Voter
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true)
|
||||
&& ($subject instanceof Season || $subject instanceof Elimination);
|
||||
&& (
|
||||
$subject instanceof Season
|
||||
|| $subject instanceof Elimination
|
||||
|| $subject instanceof Quiz
|
||||
|| $subject instanceof Candidate
|
||||
|| $subject instanceof Answer
|
||||
|| $subject instanceof Question
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Season|Elimination $subject */
|
||||
/** @param Season|Elimination|Quiz|Candidate|Answer|Question $subject */
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
@@ -37,7 +48,24 @@ final class SeasonVoter extends Voter
|
||||
return true;
|
||||
}
|
||||
|
||||
$season = $subject instanceof Season ? $subject : $subject->getQuiz()->getSeason();
|
||||
switch (true) {
|
||||
case $subject instanceof Answer:
|
||||
$season = $subject->getQuestion()->getQuiz()->getSeason();
|
||||
break;
|
||||
case $subject instanceof Elimination:
|
||||
case $subject instanceof Question:
|
||||
$season = $subject->getQuiz()->getSeason();
|
||||
break;
|
||||
case $subject instanceof Candidate:
|
||||
case $subject instanceof Quiz:
|
||||
$season = $subject->getSeason();
|
||||
break;
|
||||
case $subject instanceof Season:
|
||||
$season = $subject;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($attribute) {
|
||||
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
|
||||
|
||||
@@ -11,43 +11,45 @@
|
||||
{{ 'Add'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<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 seasons %}
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td>
|
||||
<th scope="col">{{ 'Owner(s)'|trans }}</th>
|
||||
{% 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>
|
||||
<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>
|
||||
{% else %}
|
||||
EMPTY
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for season in seasons %}
|
||||
<tr class="align-middle">
|
||||
{% 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 %}
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
{% block body %}
|
||||
<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 %}"
|
||||
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ 'Make active'|trans }}</a>
|
||||
{% if quiz is same as (season.activeQuiz) %}
|
||||
<a class="btn btn-secondary"
|
||||
href="{{ path('app_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}">{{ 'Deactivate Quiz'|trans }}</a>
|
||||
{% 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 id="questions">
|
||||
@@ -74,10 +80,10 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ 'Candidate'|trans }}</th>
|
||||
<th scope="col">{{ 'Correct Answers'|trans }}</th>
|
||||
<th scope="col">{{ 'Corrections'|trans }}</th>
|
||||
<th scope="col">{{ 'Score'|trans }}</th>
|
||||
<th scope="col">{{ 'Time'|trans }}</th>
|
||||
<th style="width: 15%" scope="col">{{ 'Correct Answers'|trans }}</th>
|
||||
<th style="width: 20%" scope="col">{{ 'Corrections'|trans }}</th>
|
||||
<th style="width: 10%" scope="col">{{ 'Score'|trans }}</th>
|
||||
<th style="width: 20%" scope="col">{{ 'Time'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -85,7 +91,21 @@
|
||||
<tr class="table-{% if loop.revindex > quiz.dropouts %}success{% else %}danger{% endif %}">
|
||||
<td>{{ candidate.name }}</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.time }}</td>
|
||||
</tr>
|
||||
@@ -97,16 +117,48 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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 %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="elimination-screen" id="{{ colour }}"
|
||||
alt="Screen with colour {{ colour }}"
|
||||
data-controller="elimination"
|
||||
data-action="click->elimination#next"
|
||||
data-action="click->elimination#next keydown@document->elimination#next"
|
||||
tabindex="0"
|
||||
>
|
||||
|
||||
|
||||
77
tests/Security/Voter/SeasonVoterTest.php
Normal file
77
tests/Security/Voter/SeasonVoterTest.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,10 @@
|
||||
<source>Candidates</source>
|
||||
<target>Kandidaten</target>
|
||||
</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">
|
||||
<source>Correct Answers</source>
|
||||
<target>Goede antwoorden</target>
|
||||
@@ -77,6 +81,10 @@
|
||||
<source>Deactivate Quiz</source>
|
||||
<target>Deactiveer test</target>
|
||||
</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">
|
||||
<source>Download Template</source>
|
||||
<target>Download sjabloon</target>
|
||||
@@ -93,6 +101,10 @@
|
||||
<source>Enter your name</source>
|
||||
<target>Voor je naam in</target>
|
||||
</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">
|
||||
<source>Green</source>
|
||||
<target>Groen</target>
|
||||
@@ -177,10 +189,18 @@
|
||||
<source>Quiz Added!</source>
|
||||
<target>Test toegevoegd!</target>
|
||||
</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">
|
||||
<source>Quiz completed</source>
|
||||
<target>Test voltooid</target>
|
||||
</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">
|
||||
<source>Quiz name</source>
|
||||
<target>Testnaam</target>
|
||||
@@ -237,10 +257,6 @@
|
||||
<source>Sign in</source>
|
||||
<target>Log in</target>
|
||||
</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">
|
||||
<source>Submit</source>
|
||||
<target>Verstuur</target>
|
||||
@@ -253,10 +269,18 @@
|
||||
<source>There are no answers for this question</source>
|
||||
<target>Er zijn geen antwoorden voor deze vraag</target>
|
||||
</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">
|
||||
<source>Time</source>
|
||||
<target>Tijd</target>
|
||||
</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">
|
||||
<source>Your Seasons</source>
|
||||
<target>Jouw seizoenen</target>
|
||||
|
||||
Reference in New Issue
Block a user