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 @@
+
{{ t('Seasons') }}
+ href="{{ path('backoffice_index') }}">{{ 'Seasons'|trans }}
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 }}
- | {{ t('Candidate') }} |
- {{ t('Correct Answers') }} |
- {{ t('Corrections') }} |
- {{ t('Score') }} |
- {{ t('Time') }} |
+ {{ 'Candidate'|trans }} |
+ {{ 'Correct Answers'|trans }} |
+ {{ 'Corrections'|trans }} |
+ {{ 'Score'|trans }} |
+ {{ 'Time'|trans }} |
- {# {% with result = quiz.get_score %} #}
- {# {% for candidate in result %} #}
- {# #}
- {# | {{ candidate.name }} | #}
- {# {{ candidate.correct }} | #}
- {# {{ candidate.corrections }} | #}
- {# {{ candidate.score }} | #}
- {# {{ candidate.time }} | #}
- {#
#}
- {# {% empty %} #}
- {# {% endfor %} #}
+ {% for candidate in result %}
+
+ | {{ candidate.0.name }} |
+ {{ candidate.correct|default('0') }} |
+ {{ candidate.corrections|default('0') }} |
+ {{ candidate.score|default('x') }} |
+ {{ candidate.time }} |
+
+ {% else %}
+
+ | {{ 'No results'|trans }} |
+
+ {% endfor %}
- {# {% 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 }}
-
{{ t('Quizzes') }}
+
{{ 'Quizzes'|trans }}
{% for quiz in season.quizzes %}
-
{{ t('Candidates') }}
+
{{ 'Candidates'|trans }}
{% for candidate in season.candidates %}
- {{ candidate.name }}
{% endfor %}
diff --git a/translations/messages+intl-icu.nl.yaml b/translations/messages+intl-icu.nl.yaml
index 604c258..792cb4b 100644
--- a/translations/messages+intl-icu.nl.yaml
+++ b/translations/messages+intl-icu.nl.yaml
@@ -8,6 +8,7 @@ Corrections: Jokers
Manage: Beheren
Name: Naam
'No active quiz': 'Geen actieve test'
+'No results': 'Geen resultaten'
'Number of dropouts:': 'Aantal afvallers:'
'Prepare Custom Elimination': 'Bereid aangepaste eliminatie voor'
'Preregister?': 'Voorregistreren?'