From acf5c06fcc6f29186b555c7d7d10a7b6102916f7 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Wed, 12 Mar 2025 23:18:13 +0100 Subject: [PATCH] Refactor code for improved readability and consistency; add flash message handling and enhance quiz functionality --- .php-cs-fixer.dist.php | 11 ++- config/bundles.php | 33 +++++--- public/index.php | 4 +- src/Command/TestCommand.php | 34 ++++++++ src/Controller/AbstractController.php | 2 + src/Controller/Admin/DashboardController.php | 3 + src/Controller/BackofficeController.php | 16 ++-- src/Controller/QuizController.php | 3 +- src/Entity/Elimination.php | 42 ++++++++++ src/Entity/Question.php | 19 +++++ src/Form/EnterNameType.php | 4 +- src/Helpers/Base64.php | 4 +- src/Repository/CandidateRepository.php | 54 ++++++++++++- src/Repository/EliminationRepository.php | 45 +++++++++++ src/Repository/GivenAnswerRepository.php | 2 +- src/Repository/QuizRepository.php | 4 +- src/Service/EliminationService.php | 16 ++++ templates/backoffice/nav.html.twig | 2 +- templates/backoffice/quiz.html.twig | 84 ++++++++++---------- templates/backoffice/season.html.twig | 6 +- translations/messages+intl-icu.nl.yaml | 1 + 21 files changed, 309 insertions(+), 80 deletions(-) create mode 100644 src/Command/TestCommand.php create mode 100644 src/Entity/Elimination.php create mode 100644 src/Repository/EliminationRepository.php create mode 100644 src/Service/EliminationService.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 90c648c..bf379e0 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,17 +1,20 @@ in(__DIR__) ->exclude('var') ; -return (new PhpCsFixer\Config()) +return (new Config()) ->setRules([ '@Symfony' => true, '@Symfony:risky' => true, 'declare_strict_types' => true, + 'fully_qualified_strict_types' => ['import_symbols' => true], 'linebreak_after_opening_tag' => true, 'mb_str_functions' => true, 'no_php4_constructor' => true, @@ -19,11 +22,11 @@ return (new PhpCsFixer\Config()) 'no_useless_else' => true, 'no_useless_return' => true, 'php_unit_strict' => true, + 'phpdoc_line_span' => ['const' => 'single', 'method' => 'single', 'property' => 'single'], 'phpdoc_order' => true, + 'single_line_empty_body' => true, 'strict_comparison' => true, 'strict_param' => true, - 'blank_line_between_import_groups' => false, - 'phpdoc_line_span' => ['const' => 'single', 'method' => 'single', 'property' => 'single'], ]) ->setRiskyAllowed(true) ->setFinder($finder) diff --git a/config/bundles.php b/config/bundles.php index 8de0245..42d43d0 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -1,17 +1,28 @@ ['all' => true], - Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], - Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], - Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], - Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], - Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], - Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], - Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], - Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], - EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true], - Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + FrameworkBundle::class => ['all' => true], + DoctrineBundle::class => ['all' => true], + DoctrineMigrationsBundle::class => ['all' => true], + MakerBundle::class => ['dev' => true], + TwigBundle::class => ['all' => true], + SecurityBundle::class => ['all' => true], + WebProfilerBundle::class => ['dev' => true, 'test' => true], + TwigExtraBundle::class => ['all' => true], + TwigComponentBundle::class => ['all' => true], + EasyAdminBundle::class => ['all' => true], + DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], ]; diff --git a/public/index.php b/public/index.php index 12db4f8..3555cc2 100644 --- a/public/index.php +++ b/public/index.php @@ -7,8 +7,8 @@ use App\Kernel; require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; return static function (array $context): Kernel { - $appEnv = !empty($context['APP_ENV']) ? (string) $context['APP_ENV'] : 'prod'; - $appDebug = !empty($context['APP_DEBUG']) ? filter_var($context['APP_DEBUG'], \FILTER_VALIDATE_BOOL) : 'prod' !== $appEnv; + $appEnv = empty($context['APP_ENV']) ? 'prod' : (string) $context['APP_ENV']; + $appDebug = empty($context['APP_DEBUG']) ? 'prod' !== $appEnv : filter_var($context['APP_DEBUG'], \FILTER_VALIDATE_BOOL); return new Kernel($appEnv, $appDebug); }; diff --git a/src/Command/TestCommand.php b/src/Command/TestCommand.php new file mode 100644 index 0000000..b6f3349 --- /dev/null +++ b/src/Command/TestCommand.php @@ -0,0 +1,34 @@ +candidateRepository->getScores($this->quizRepository->find('1effa06a-8aca-6c52-b52b-3974eda7eed7'))); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php index 1853af9..347eccf 100644 --- a/src/Controller/AbstractController.php +++ b/src/Controller/AbstractController.php @@ -9,11 +9,13 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBase abstract class AbstractController extends AbstractBaseController { + #[\Override] protected function addFlash(FlashType|string $type, mixed $message): void { if ($type instanceof FlashType) { $type = $type->value; } + parent::addFlash($type, $message); } } diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 2886c50..de1bc3e 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -22,6 +22,7 @@ use Symfony\Component\Routing\Attribute\Route; class DashboardController extends AbstractDashboardController { #[Route('/admin', name: 'admin')] + #[\Override] public function index(): Response { // return parent::index(); @@ -44,12 +45,14 @@ class DashboardController extends AbstractDashboardController // return $this->render('some/path/my-dashboard.html.twig'); } + #[\Override] public function configureDashboard(): Dashboard { return Dashboard::new() ->setTitle('TijdVoorDeTest'); } + #[\Override] public function configureMenuItems(): iterable { yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); diff --git a/src/Controller/BackofficeController.php b/src/Controller/BackofficeController.php index fbd8999..e972bd4 100644 --- a/src/Controller/BackofficeController.php +++ b/src/Controller/BackofficeController.php @@ -6,6 +6,7 @@ namespace App\Controller; use App\Entity\Quiz; use App\Entity\Season; +use App\Repository\CandidateRepository; use App\Repository\SeasonRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -13,14 +14,14 @@ use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Routing\Attribute\Route; #[AsController] -#[Route('/backoffice', name: 'backoffice_')] final class BackofficeController extends AbstractController { - public function __construct(private readonly SeasonRepository $seasonRepository) - { - } + public function __construct( + private readonly SeasonRepository $seasonRepository, + private readonly CandidateRepository $candidateRepository, + ) {} - #[Route('/', name: 'index')] + #[Route('/backoffice/', name: 'index')] public function index(): Response { $seasons = $this->seasonRepository->findAll(); @@ -30,7 +31,7 @@ final class BackofficeController extends AbstractController ]); } - #[Route('/{seasonCode}', name: 'season')] + #[Route('/backoffice/{seasonCode}', name: 'season')] public function season(Season $season): Response { return $this->render('backoffice/season.html.twig', [ @@ -38,12 +39,13 @@ final class BackofficeController extends AbstractController ]); } - #[Route('/{seasonCode}/{quiz}', name: 'quiz')] + #[Route('/backoffice/{seasonCode}/{quiz}', name: 'quiz')] public function quiz(Season $season, Quiz $quiz): Response { return $this->render('backoffice/quiz.html.twig', [ 'season' => $season, 'quiz' => $quiz, + 'result' => $this->candidateRepository->getScores($quiz), ]); } } diff --git a/src/Controller/QuizController.php b/src/Controller/QuizController.php index bf5e28c..7e483dd 100644 --- a/src/Controller/QuizController.php +++ b/src/Controller/QuizController.php @@ -28,6 +28,7 @@ use Symfony\Component\Routing\Attribute\Route; final class QuizController extends AbstractController { public const string SEASON_CODE_REGEX = '[A-Za-z\d]{5}'; + private const string CANDIDATE_HASH_REGEX = '[\w\-=]+'; #[Route(path: '/', name: 'select_season', methods: ['GET', 'POST'])] @@ -83,7 +84,7 @@ final class QuizController extends AbstractController $candidate = $candidateRepository->getCandidateByHash($season, $nameHash); if (!$candidate instanceof Candidate) { - if (true === $season->isPreregisterCandidates()) { + if ($season->isPreregisterCandidates()) { $this->addFlash(FlashType::Danger, 'Candidate not found'); return $this->redirectToRoute('enter_name', ['seasonCode' => $season->getSeasonCode()]); diff --git a/src/Entity/Elimination.php b/src/Entity/Elimination.php new file mode 100644 index 0000000..ec47233 --- /dev/null +++ b/src/Entity/Elimination.php @@ -0,0 +1,42 @@ +id; + } + + public function getData(): array + { + return $this->data; + } + + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } +} diff --git a/src/Entity/Question.php b/src/Entity/Question.php index 9ea7020..cddcd36 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -96,4 +96,23 @@ class Question return $this; } + + public function getErrors(): ?string + { + if (0 === \count($this->answers)) { + return 'This question has no answers'; + } + + $correctAnswers = $this->answers->filter(static fn (Answer $answer): ?bool => $answer->isRightAnswer())->count(); + + if (0 === $correctAnswers) { + return 'This question has no correct answers'; + } + + if ($correctAnswers > 1) { + return 'This question has multiple correct answers'; + } + + return null; + } } diff --git a/src/Form/EnterNameType.php b/src/Form/EnterNameType.php index b130296..f3c3373 100644 --- a/src/Form/EnterNameType.php +++ b/src/Form/EnterNameType.php @@ -11,9 +11,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; class EnterNameType extends AbstractType { - public function __construct(private TranslatorInterface $translator) - { - } + public function __construct(private readonly TranslatorInterface $translator) {} public function buildForm(FormBuilderInterface $builder, array $options): void { diff --git a/src/Helpers/Base64.php b/src/Helpers/Base64.php index 991c9e5..8fbba4e 100644 --- a/src/Helpers/Base64.php +++ b/src/Helpers/Base64.php @@ -8,9 +8,7 @@ use Safe\Exceptions\UrlException; class Base64 { - private function __construct() - { - } + private function __construct() {} public static function base64_url_encode(string $input): string { diff --git a/src/Repository/CandidateRepository.php b/src/Repository/CandidateRepository.php index cda370c..fdf622c 100644 --- a/src/Repository/CandidateRepository.php +++ b/src/Repository/CandidateRepository.php @@ -5,14 +5,20 @@ declare(strict_types=1); namespace App\Repository; use App\Entity\Candidate; +use App\Entity\Correction; +use App\Entity\Quiz; use App\Entity\Season; use App\Helpers\Base64; +use DateInterval; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\Persistence\ManagerRegistry; use Safe\Exceptions\UrlException; /** * @extends ServiceEntityRepository + * + * @phpstan-type ResultArray array */ class CandidateRepository extends ServiceEntityRepository { @@ -41,8 +47,54 @@ class CandidateRepository extends ServiceEntityRepository { $this->getEntityManager()->persist($candidate); - if (true === $flush) { + if ($flush) { $this->getEntityManager()->flush(); } } + + /** @return ResultArray */ + public function getScores(Quiz $quiz): array + { + $scoreTimeQb = $this->createQueryBuilder('c', 'c.id') + ->select('c', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'max(ga.created) - min(ga.created) as time') + ->join('c.givenAnswers', 'ga') + ->join('ga.answer', 'a') + ->where('ga.quiz = :quiz') + ->groupBy('c.id') + ->setParameter('quiz', $quiz); + + $correctionsQb = $this->createQueryBuilder('c', 'c.id') + ->select('c', 'cor.amount as corrections') + ->innerJoin(Correction::class, 'cor', Join::WITH, 'cor.candidate = c and cor.quiz = :quiz') + ->setParameter('quiz', $quiz); + + $merged = array_merge_recursive($scoreTimeQb->getQuery()->getArrayResult(), $correctionsQb->getQuery()->getArrayResult()); + + return $this->sortResults($this->calculateScore($merged)); + } + + /** + * @param array $in + * + * @return ResultArray + */ + private function calculateScore(array $in): array + { + return array_map(static fn ($candidate): array => [ + ...$candidate, + 'score' => $candidate['correct'] + ($candidate['corrections'] ?? 0.0), + ], $in); + } + + /** + * @param ResultArray $results + * + * @return ResultArray + */ + private function sortResults(array $results): array + { + usort($results, static fn ($a, $b): int => $b['score'] <=> $a['score']); + + return $results; + } } diff --git a/src/Repository/EliminationRepository.php b/src/Repository/EliminationRepository.php new file mode 100644 index 0000000..60782c2 --- /dev/null +++ b/src/Repository/EliminationRepository.php @@ -0,0 +1,45 @@ + + */ +class EliminationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Elimination::class); + } + + // /** + // * @return Elimination[] Returns an array of Elimination objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('e') + // ->andWhere('e.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('e.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Elimination + // { + // return $this->createQueryBuilder('e') + // ->andWhere('e.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/GivenAnswerRepository.php b/src/Repository/GivenAnswerRepository.php index 116b418..3e9423f 100644 --- a/src/Repository/GivenAnswerRepository.php +++ b/src/Repository/GivenAnswerRepository.php @@ -22,7 +22,7 @@ class GivenAnswerRepository extends ServiceEntityRepository { $this->getEntityManager()->persist($givenAnswer); - if (true === $flush) { + if ($flush) { $this->getEntityManager()->flush(); } } diff --git a/src/Repository/QuizRepository.php b/src/Repository/QuizRepository.php index 3f3ab79..b9ffadb 100644 --- a/src/Repository/QuizRepository.php +++ b/src/Repository/QuizRepository.php @@ -18,7 +18,5 @@ class QuizRepository extends ServiceEntityRepository parent::__construct($registry, Quiz::class); } - public function quizReault(Quiz $quiz): array - { - } + public function quizReault(Quiz $quiz): array {} } diff --git a/src/Service/EliminationService.php b/src/Service/EliminationService.php new file mode 100644 index 0000000..c23a2c7 --- /dev/null +++ b/src/Service/EliminationService.php @@ -0,0 +1,16 @@ + diff --git a/templates/backoffice/quiz.html.twig b/templates/backoffice/quiz.html.twig index 488b64a..c7e9590 100644 --- a/templates/backoffice/quiz.html.twig +++ b/templates/backoffice/quiz.html.twig @@ -2,11 +2,11 @@ {% block body %}

-

{{ t('Quiz') }}: {{ quiz.season.name }} - {{ quiz.name }}

+

{{ 'Quiz'|trans }}: {{ quiz.season.name }} - {{ quiz.name }}

-

{{ t('Questions') }}

+

{{ 'Questions'|trans }}

{% for question in quiz.questions %} @@ -17,24 +17,25 @@ data-bs-toggle="collapse" data-bs-target="#question-{{ loop.index0 }}" aria-controls="question-{{ loop.index0 }}"> - {# {% with question_error = question.errors %} #} - {# {% if question_error %} #} - {# ! #} - {# {% endif %} #} - {# {% endwith %} #} + {% set questionErrors = question.getErrors %} + {% if questionErrors %} + ! + {% endif %} {{ loop.index }}. {{ question.question }}
- {% for answer in question.answers %} -
  • {{ answer.text }}
  • - {% else %} - {{ t('There are no answers for this question') }} - {% endfor %} +
      + {% for answer in question.answers %} +
    • {{ answer.text }}
    • + {% else %} + {{ 'There are no answers for this question'|trans }} + {% endfor %} +
    @@ -45,51 +46,54 @@

    -

    {{ t('Score') }}

    +

    {{ 'Score'|trans }}

    -

    {{ t('Number of dropouts:') }} {{ quiz.dropouts }}

    +

    {{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }}

    - - - - - + + + + + - {# {% with result = quiz.get_score %} #} - {# {% for candidate in result %} #} - {# #} - {# #} - {# #} - {# #} - {# #} - {# #} - {# #} - {# {% empty %} #} - {# {% endfor %} #} + {% for candidate in result %} + + + + + + + + {% else %} + + + + {% endfor %}
    {{ t('Candidate') }}{{ t('Correct Answers') }}{{ t('Corrections') }}{{ t('Score') }}{{ t('Time') }}{{ 'Candidate'|trans }}{{ 'Correct Answers'|trans }}{{ 'Corrections'|trans }}{{ 'Score'|trans }}{{ 'Time'|trans }}
    {{ candidate.name }}{{ candidate.correct }}{{ candidate.corrections }}{{ candidate.score }}{{ candidate.time }}
    {{ candidate.0.name }}{{ candidate.correct|default('0') }}{{ candidate.corrections|default('0') }}{{ candidate.score|default('x') }}{{ candidate.time }}
    {{ 'No results'|trans }}
    - {# {% endwith %} #}
    {% endblock %} -{% block script %} +{% block javascripts %} -{% endblock script %} +{% endblock javascripts %} {% block title %} {% endblock %} diff --git a/templates/backoffice/season.html.twig b/templates/backoffice/season.html.twig index c62abc8..c0515e4 100644 --- a/templates/backoffice/season.html.twig +++ b/templates/backoffice/season.html.twig @@ -1,11 +1,11 @@ {% extends 'backoffice/base.html.twig' %} {% block body %}

    -

    {{ t('Season') }}: {{ season.name }}

    +

    {{ 'Season'|trans }}: {{ season.name }}