19 Commits

Author SHA1 Message Date
cfb69c8dab Add Deploy step 2025-09-08 21:51:00 +02:00
35ec71302b Update bake 2025-09-08 19:53:23 +02:00
b7a570928a Update dependencies 2025-09-08 19:50:23 +02:00
d80436534f Update Symfony packages 2025-09-08 19:26:45 +02:00
dependabot[bot]
14e2dd490e Bump phpstan/phpstan-symfony from 2.0.6 to 2.0.7
Bumps [phpstan/phpstan-symfony](https://github.com/phpstan/phpstan-symfony) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/phpstan/phpstan-symfony/releases)
- [Commits](https://github.com/phpstan/phpstan-symfony/compare/2.0.6...2.0.7)

---
updated-dependencies:
- dependency-name: phpstan/phpstan-symfony
  dependency-version: 2.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-25 13:30:05 +02:00
dependabot[bot]
68e54b1110 Bump doctrine/doctrine-bundle from 2.14.0 to 2.15.0
Bumps [doctrine/doctrine-bundle](https://github.com/doctrine/DoctrineBundle) from 2.14.0 to 2.15.0.
- [Release notes](https://github.com/doctrine/DoctrineBundle/releases)
- [Commits](https://github.com/doctrine/DoctrineBundle/compare/2.14.0...2.15.0)

---
updated-dependencies:
- dependency-name: doctrine/doctrine-bundle
  dependency-version: 2.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-25 13:29:46 +02:00
dependabot[bot]
e615b2cdea Bump symfony/serializer from 7.3.0 to 7.3.1
Bumps [symfony/serializer](https://github.com/symfony/serializer) from 7.3.0 to 7.3.1.
- [Release notes](https://github.com/symfony/serializer/releases)
- [Changelog](https://github.com/symfony/serializer/blob/7.3/CHANGELOG.md)
- [Commits](https://github.com/symfony/serializer/compare/v7.3.0...v7.3.1)

---
updated-dependencies:
- dependency-name: symfony/serializer
  dependency-version: 7.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-25 13:29:34 +02:00
dependabot[bot]
4b45a2c557 Bump symfony/security-bundle from 7.3.0 to 7.3.1
Bumps [symfony/security-bundle](https://github.com/symfony/security-bundle) from 7.3.0 to 7.3.1.
- [Release notes](https://github.com/symfony/security-bundle/releases)
- [Changelog](https://github.com/symfony/security-bundle/blob/7.3/CHANGELOG.md)
- [Commits](https://github.com/symfony/security-bundle/compare/v7.3.0...v7.3.1)

---
updated-dependencies:
- dependency-name: symfony/security-bundle
  dependency-version: 7.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-25 13:29:24 +02:00
c6fe553341 Create dependabot.yml 2025-07-25 13:04:20 +02:00
69a2b9c811 Open db port 2025-06-15 02:18:27 +02:00
f31a7d527d Fix scores? 2025-06-14 21:34:52 +02:00
ed3cf7644f Correct sorting for scores 2025-06-14 12:32:18 +02:00
77d21b004f Update backoffice templates to dynamically include titles and improve candidate handling in SeasonController 2025-06-14 12:32:18 +02:00
379fafcd16 Fix cs 2025-06-12 15:03:01 +02:00
7586d2d8ac Merge branch 'main' of github.com:MarijnDoeve/TijdVoorDeTest 2025-06-11 18:27:04 +02:00
9e41376244 Translations! 2025-06-11 18:26:17 +02:00
2bfef94bbe Add settings management for seasons, update templates, migrations, and commands 2025-06-10 23:06:59 +02:00
a8c4cba968 Disable Turbo for now 2025-06-09 15:52:15 +02:00
d5566d4737 Refactor repositories to use DQL queries, simplify logic, and enhance query efficiency 2025-06-09 14:19:10 +02:00
40 changed files with 1419 additions and 868 deletions

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "composer" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

View File

@@ -4,6 +4,8 @@ on:
push:
branches:
- main
tags:
- '*'
pull_request: ~
workflow_dispatch: ~
@@ -18,10 +20,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Lint Dockerfile
uses: hadolint/hadolint-action@v3.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker images
uses: docker/bake-action@v4
uses: docker/bake-action@v5
with:
pull: true
load: true
@@ -53,12 +57,17 @@ jobs:
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
lint:
name: Docker Lint
deploy:
name: Deploy
environment:
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
url: ${{ vars.URL }}
needs: tests
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Lint Dockerfile
uses: hadolint/hadolint-action@v3.1.0
- shell: bash
env:
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
run: |
curl -v -X POST "$PORTAINER_WEBHOOK"

5
.idea/php.xml generated
View File

@@ -26,6 +26,11 @@
<phpcs_fixer_by_interpreter asDefaultInterpreter="true" deletedFromTheList="true" interpreter_id="96512cb2-7b9e-4e1d-bfa2-bf7f3be424c8" standards="DoctrineAnnotation;PER;PER-CS;PER-CS1.0;PER-CS2.0;PHP54Migration;PHP56Migration;PHP70Migration;PHP71Migration;PHP73Migration;PHP74Migration;PHP80Migration;PHP81Migration;PHP82Migration;PHP83Migration;PHP84Migration;PHPUnit100Migration;PHPUnit30Migration;PHPUnit32Migration;PHPUnit35Migration;PHPUnit43Migration;PHPUnit48Migration;PHPUnit50Migration;PHPUnit52Migration;PHPUnit54Migration;PHPUnit55Migration;PHPUnit56Migration;PHPUnit57Migration;PHPUnit60Migration;PHPUnit75Migration;PHPUnit84Migration;PHPUnit91Migration;PSR1;PSR12;PSR2;PhpCsFixer;Symfony" tool_path="vendor/bin/php-cs-fixer" timeout="30000" />
</phpcsfixer_settings>
</component>
<component name="PhpCodeSniffer">
<phpcs_settings>
<phpcs_by_interpreter asDefaultInterpreter="true" interpreter_id="96512cb2-7b9e-4e1d-bfa2-bf7f3be424c8" timeout="30000" />
</phpcs_settings>
</component>
<component name="PhpExternalFormatter">
<option name="externalFormatter" value="PHP_CS_FIXER" />
</component>

View File

@@ -1,15 +1,15 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": false,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
}

View File

@@ -24,6 +24,8 @@ services:
database:
networks:
- internal
ports:
- "5430:5432"
networks:
web:
external: true

View File

@@ -9,21 +9,21 @@
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^4.2.3",
"doctrine/doctrine-bundle": "^2.14.0",
"doctrine/dbal": "^4.3.3",
"doctrine/doctrine-bundle": "^2.16.1",
"doctrine/doctrine-migrations-bundle": "^3.4.2",
"doctrine/orm": "^3.3.3",
"easycorp/easyadmin-bundle": "^4.24.7",
"phpdocumentor/reflection-docblock": "^5.6.2",
"phpoffice/phpspreadsheet": "^4.3.1",
"phpstan/phpdoc-parser": "^2.1",
"doctrine/orm": "^3.5.2",
"easycorp/easyadmin-bundle": "^4.25.0",
"phpdocumentor/reflection-docblock": "^5.6.3",
"phpoffice/phpspreadsheet": "^5.1",
"phpstan/phpdoc-parser": "^2.3",
"runtime/frankenphp-symfony": "^0.2.0",
"sentry/sentry-symfony": "^5.2",
"sentry/sentry-symfony": "^5.4",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/flex": "^2.7.1",
"symfony/flex": "^2.8.2",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/mailer": "7.3.*",
@@ -35,10 +35,10 @@
"symfony/serializer": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/uid": "7.3.*",
"symfony/ux-turbo": "^2.26.1",
"symfony/ux-turbo": "^2.30.0",
"symfony/yaml": "7.3.*",
"symfonycasts/sass-bundle": "^0.8.2",
"symfonycasts/verify-email-bundle": "^1.17.3",
"symfonycasts/sass-bundle": "^0.8.3",
"symfonycasts/verify-email-bundle": "^1.17.4",
"thecodingmachine/safe": "^3.3.0",
"twig/extra-bundle": "^3.21",
"twig/intl-extra": "^3.21",
@@ -46,23 +46,23 @@
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.1",
"friendsofphp/php-cs-fixer": "^3.75.0",
"friendsofphp/php-cs-fixer": "^3.87.1",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.17",
"phpstan/phpstan-doctrine": "^2.0.3",
"phpstan/phpstan-phpunit": "^2.0.6",
"phpstan/phpstan-symfony": "^2.0.6",
"phpunit/phpunit": "^12.2.1",
"rector/rector": "^2.0.17",
"phpstan/phpstan": "^2.1.22",
"phpstan/phpstan-doctrine": "^2.0.5",
"phpstan/phpstan-phpunit": "^2.0.7",
"phpstan/phpstan-symfony": "^2.0.8",
"phpunit/phpunit": "^12.3.8",
"rector/rector": "^2.1.6",
"roave/security-advisories": "dev-latest",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/maker-bundle": "^1.63.0",
"symfony/maker-bundle": "^1.64.0",
"symfony/phpunit-bridge": "7.3.*",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*",
"thecodingmachine/phpstan-safe-rule": "^1.4.1",
"vincentlanglet/twig-cs-fixer": "^3.7.1"
"vincentlanglet/twig-cs-fixer": "^3.9.0"
},
"config": {
"allow-plugins": {

1678
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@ security:
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/backoffice, roles: ROLE_USER }
when@test:
security:

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250610210417 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE season_settings (id UUID NOT NULL, show_numbers BOOLEAN DEFAULT false NOT NULL, confirm_answers BOOLEAN DEFAULT false NOT NULL, PRIMARY KEY(id))
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE season ADD settings_id UUID DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE season ADD CONSTRAINT FK_F0E45BA959949888 FOREIGN KEY (settings_id) REFERENCES season_settings (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX UNIQ_F0E45BA959949888 ON season (settings_id)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
DROP TABLE season_settings
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE season DROP CONSTRAINT FK_F0E45BA959949888
SQL);
$this->addSql(<<<'SQL'
DROP INDEX UNIQ_F0E45BA959949888
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE season DROP settings_id
SQL);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\SeasonSettings;
use App\Repository\SeasonRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:add-settings',
description: 'Add a short description for your command',
)]
readonly class AddSettingsCommand
{
public function __construct(private SeasonRepository $seasonRepository, private EntityManagerInterface $entityManager) {}
public function __invoke(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
foreach ($this->seasonRepository->findAll() as $season) {
if (null !== $season->getSettings()) {
continue;
}
$io->text('Adding settings to season : '.$season->getSeasonCode());
$season->setSettings(new SeasonSettings());
}
$this->entityManager->flush();
return Command::SUCCESS;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Controller\Admin;
use App\Entity\Answer;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
/** @extends AbstractCrudController<Answer> */
class AnswerCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string

View File

@@ -7,6 +7,7 @@ namespace App\Controller\Admin;
use App\Entity\Candidate;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
/** @extends AbstractCrudController<Candidate> */
class CandidateCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string

View File

@@ -7,6 +7,7 @@ namespace App\Controller\Admin;
use App\Entity\GivenAnswer;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
/** @extends AbstractCrudController<GivenAnswer> */
class GivenAnswerCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string

View File

@@ -7,6 +7,7 @@ namespace App\Controller\Admin;
use App\Entity\Question;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
/** @extends AbstractCrudController<Question> */
class QuestionCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string

View File

@@ -7,7 +7,8 @@ namespace App\Controller\Admin;
use App\Entity\QuizCandidate;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
class CorrectionCrudController extends AbstractCrudController
/** @extends AbstractCrudController<QuizCandidate> */
class QuizCorrectionCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{

View File

@@ -7,6 +7,7 @@ namespace App\Controller\Admin;
use App\Entity\Quiz;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
/** @extends AbstractCrudController<Quiz> */
class QuizCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string

View File

@@ -7,6 +7,7 @@ namespace App\Controller\Admin;
use App\Entity\Season;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
/** @extends AbstractCrudController<Season> */
class SeasonCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string

View File

@@ -7,6 +7,7 @@ namespace App\Controller\Admin;
use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
/** @extends AbstractCrudController<User> */
class UserCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string

View File

@@ -51,7 +51,7 @@ class QuizController extends AbstractController
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/enable',
name: 'app_backoffice_enable',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'],
)]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): RedirectResponse

View File

@@ -10,6 +10,7 @@ use App\Entity\Quiz;
use App\Entity\Season;
use App\Enum\FlashType;
use App\Form\AddCandidatesFormType;
use App\Form\SettingsForm;
use App\Form\UploadQuizFormType;
use App\Security\Voter\SeasonVoter;
use App\Service\QuizSpreadsheetService;
@@ -26,7 +27,9 @@ use Symfony\Contracts\Translation\TranslatorInterface;
#[IsGranted('ROLE_USER')]
class SeasonController extends AbstractController
{
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $em,
public function __construct(
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em,
) {}
#[Route(
@@ -35,10 +38,19 @@ class SeasonController extends AbstractController
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
)]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function index(Season $season): Response
public function index(Season $season, Request $request): Response
{
$form = $this->createForm(SettingsForm::class, $season->getSettings());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
}
return $this->render('backoffice/season.html.twig', [
'season' => $season,
'form' => $form,
]);
}
@@ -57,7 +69,7 @@ class SeasonController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$candidates = $form->get('candidates')->getData();
foreach (explode("\n", (string) $candidates) as $candidate) {
$season->addCandidate(new Candidate($candidate));
$season->addCandidate(new Candidate(mb_rtrim($candidate)));
}
$this->em->flush();

View File

@@ -125,6 +125,6 @@ final class QuizController extends AbstractController
$quizCandidateRepository->createIfNotExist($quiz, $candidate);
return $this->render('quiz/question.twig', ['candidate' => $candidate, 'question' => $question]);
return $this->render('quiz/question.twig', ['candidate' => $candidate, 'question' => $question, 'season' => $season]);
}
}

View File

@@ -57,6 +57,7 @@ final class RegistrationController extends AbstractController
} catch (TransportExceptionInterface $e) {
$logger->error($e->getMessage());
}
$response = $security->login($user, 'form_login', 'main');
\assert($response instanceof Response);

View File

@@ -46,8 +46,13 @@ class Season
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Quiz $ActiveQuiz = null;
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(nullable: true)]
private ?SeasonSettings $settings = null;
public function __construct()
{
$this->settings = new SeasonSettings();
$this->quizzes = new ArrayCollection();
$this->candidates = new ArrayCollection();
$this->owners = new ArrayCollection();
@@ -166,4 +171,16 @@ class Season
return $this;
}
public function getSettings(): ?SeasonSettings
{
return $this->settings;
}
public function setSettings(SeasonSettings $settings): static
{
$this->settings = $settings;
return $this;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\SeasonSettingsRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SeasonSettingsRepository::class)]
class SeasonSettings
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
private Uuid $id;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
private bool $showNumbers = false;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
private bool $confirmAnswers = false;
public function getId(): Uuid
{
return $this->id;
}
public function isShowNumbers(): bool
{
return $this->showNumbers;
}
public function setShowNumbers(bool $showNumbers): self
{
$this->showNumbers = $showNumbers;
return $this;
}
public function isConfirmAnswers(): bool
{
return $this->confirmAnswers;
}
public function setConfirmAnswers(bool $confirmAnswers): self
{
$this->confirmAnswers = $confirmAnswers;
return $this;
}
}

View File

@@ -39,15 +39,8 @@ class RegistrationFormType extends AbstractType
'second_options' => ['label' => $this->translator->trans('Repeat Password')],
'mapped' => false,
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 8,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
new NotBlank(message: 'Please enter a password'),
new Length(min: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'),
],
'translation_domain' => false,
])

35
src/Form/SettingsForm.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\SeasonSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<SeasonSettings> */
class SettingsForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('showNumbers', options: [
'label_attr' => ['class' => 'checkbox-switch'],
'attr' => ['role' => 'switch', 'switch' => null]])
->add('confirmAnswers', options: [
'label_attr' => ['class' => 'checkbox-switch'],
'attr' => ['role' => 'switch', 'switch' => null]])
->add('save', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SeasonSettings::class,
]);
}
}

View File

@@ -31,13 +31,9 @@ class UploadQuizFormType extends AbstractType
'required' => true,
'translation_domain' => false,
'constraints' => [
new File([
'maxSize' => '1024k',
'mimeTypes' => [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
'mimeTypesMessage' => $this->translator->trans('Please upload a valid XLSX file'),
]),
new File(maxSize: '1024k', mimeTypes: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
], mimeTypesMessage: $this->translator->trans('Please upload a valid XLSX file')),
],
])
;

View File

@@ -34,12 +34,14 @@ class CandidateRepository extends ServiceEntityRepository
return null;
}
return $this->createQueryBuilder('c')
->where('c.season = :season')
->andWhere('lower(c.name) = lower(:name)')
->setParameter('season', $season)
return $this->getEntityManager()->createQuery(<<<DQL
select c from App\Entity\Candidate c
where c.season = :season
and lower(c.name) = lower(:name)
DQL
)->setParameter('season', $season)
->setParameter('name', $name)
->getQuery()->getOneOrNullResult();
->getOneOrNullResult();
}
public function save(Candidate $candidate, bool $flush = true): void
@@ -54,44 +56,22 @@ class CandidateRepository extends ServiceEntityRepository
/** @return ResultList */
public function getScores(Quiz $quiz): array
{
$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')
->join('c.quizData', 'qc')
->where('qc.quiz = :quiz')
->groupBy('ga.quiz', 'c.id', 'qc.id')
->setParameter('quiz', $quiz);
return $this->sortResults(
$this->calculateScore(
$qb->getQuery()->getResult(),
),
);
}
/**
* @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'],
], $in);
}
/**
* @param array<string, Result> $results
*
* @return ResultList
* */
private function sortResults(array $results): array
{
usort($results, static fn ($a, $b): int => $b['score'] <=> $a['score']);
return $results;
return $this->getEntityManager()->createQuery(<<<DQL
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,
(sum(case when a.isRightAnswer = true then 1 else 0 end) + qc.corrections) as score
from App\Entity\Candidate c
join c.givenAnswers ga
join ga.answer a
join c.quizData qc
where qc.quiz = :quiz and ga.quiz = :quiz
group by ga.quiz, c.id, qc.id
order by score desc, time asc
DQL
)->setParameter('quiz', $quiz)->getResult();
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Candidate;
use App\Entity\GivenAnswer;
use App\Entity\Question;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -22,22 +21,21 @@ class QuestionRepository extends ServiceEntityRepository
public function findNextQuestionForCandidate(Candidate $candidate): ?Question
{
$qb = $this->createQueryBuilder('q');
return $qb->join('q.quiz', 'qz')
->andWhere($qb->expr()->notIn('q.id', $this->getEntityManager()->createQueryBuilder()
->select('q1')
->from(GivenAnswer::class, 'ga')
->join('ga.answer', 'a')
->join('a.question', 'q1')
->andWhere($qb->expr()->isNotNull('ga.answer'))
->andWhere('ga.candidate = :candidate')
->andWhere('q1.quiz = :quiz')
->getDQL()))
->andWhere('qz = :quiz')
return $this->getEntityManager()->createQuery(<<<DQL
select q from App\Entity\Question q
join q.quiz qz
where q.id not in (
select q1.id from App\Entity\GivenAnswer ga
join ga.answer a
join a.question q1
where ga.candidate = :candidate
and q1.quiz = :quiz
)
and qz = :quiz
DQL)
->setMaxResults(1)
->setParameter('candidate', $candidate)
->setParameter('quiz', $candidate->getSeason()->getActiveQuiz())
->getQuery()->getOneOrNullResult();
->getOneOrNullResult();
}
}

View File

@@ -22,11 +22,9 @@ class SeasonRepository extends ServiceEntityRepository
/** @return list<Season> Returns an array of Season objects */
public function getSeasonsForUser(User $user): array
{
$qb = $this->createQueryBuilder('s')
->where(':user MEMBER OF s.owners')
->orderBy('s.name')
->setParameter('user', $user);
return $qb->getQuery()->getResult();
return $this->getEntityManager()->createQuery(<<<DQL
select s from App\Entity\Season s where :user member of s.owners order by s.name
DQL
)->setParameter('user', $user)->getResult();
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\SeasonSettings;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SeasonSettings>
*/
class SeasonSettingsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SeasonSettings::class);
}
}

View File

@@ -12,6 +12,7 @@ use App\Entity\Quiz;
use App\Entity\Season;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** @extends Voter<string, Season> */
@@ -37,7 +38,7 @@ final class SeasonVoter extends Voter
}
/** @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, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof User) {

View File

@@ -73,7 +73,7 @@ class QuizSpreadsheetService
*
* @throws SpreadsheetDataException
*/
private function fillQuizFromArray(Quiz $quiz, array $sheet): Quiz
private function fillQuizFromArray(Quiz $quiz, array $sheet): void
{
$errors = [];
@@ -110,8 +110,6 @@ class QuizSpreadsheetService
if ([] !== $errors) {
throw new SpreadsheetDataException($errors);
}
return $quiz;
}
public function quizToXlsx(Quiz $quiz): void {}

View File

@@ -1,3 +1,4 @@
{% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('backoffice') }}{% endblock %}
{% block title %}Tijd voor de test | {% endblock %}
{% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}Hello BackofficeController!{% endblock %}
{% block title %}{{ parent() }}Backoffice{% endblock %}
{% block body %}
<div class="d-flex flex-row align-items-center">

View File

@@ -1,5 +1,7 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}{{ parent() }}{{ quiz.season.name }}{% endblock %}
{% block body %}
<h2 class="py-2">{{ 'Quiz'|trans }}: {{ quiz.season.name }} - {{ quiz.name }}</h2>
<div class="py-2 btn-group" data-controller="bo--quiz">
@@ -51,7 +53,7 @@
</div>
</div>
{% else %}
EMPTY
{{ 'EMPTY'|trans }}
{% endfor %}
</div>
</div>
@@ -125,15 +127,16 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">Please Confirm</h1>
<h1 class="modal-title fs-5" id="staticBackdropLabel">{{ 'Please Confirm'|trans }}</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.
{{ 'Are you sure you want to clear all the results? This will also delete al the eliminations.'|trans }}
</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>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<a href="{{ path('app_backoffice_quiz_clear', {quiz: quiz.id}) }}"
class="btn btn-danger">{{ 'Yes'|trans }}</a>
</div>
</div>
</div>
@@ -146,19 +149,18 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">Please Confirm</h1>
<h1 class="modal-title fs-5" id="staticBackdropLabel">{{ 'Please Confirm'|trans }}</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?
{{ 'Are you sure you want to delete this quiz?'|trans }}
</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>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<a href="{{ path('app_backoffice_quiz_delete', {quiz: quiz.id}) }}"
class="btn btn-danger">{{ 'Yes'|trans }}</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block title %}
{% endblock %}

View File

@@ -21,5 +21,5 @@
{% endblock %}
{% block title %}
{{ parent() }}Backoffice
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}{{ parent() }}{{ season.name }}{% endblock %}
{% block body %}
<h2 class="py-2">{{ 'Season'|trans }}: {{ season.name }}</h2>
<div class="row">
@@ -21,13 +22,18 @@
<div class="d-flex flex-row align-items-center">
<h4 class="py-2 pe-2">{{ 'Candidates'|trans }}</h4>
<a class="link"
href="{{ path('app_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
href="{{ path('app_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}
</a>
</div>
<ul>
{% for candidate in season.candidates %}
<li>{{ candidate.name }}</li>{% endfor %}
</ul>
<div class="d-flex flex-row align-items-center">
<h4 class="py-2 pe-2">{{ 'Settings'|trans }}</h4>
</div>
{{ form(form) }}
</div>
<div class="col-12 col-md-3"></div>
</div>

View File

@@ -1,17 +1,35 @@
{% extends 'quiz/base.html.twig' %}
{% block body %}
{{ question.question }}<br/>
<h4>
{% if season.settings.showNumbers %}
({{ question.ordering }}/{{ question.quiz.questions.count }})
{% endif %}{{ question.question }}<br/>
</h4>
<form method="post">
<input type="hidden" name="token" value="{{ csrf_token('question') }}">
{% for answer in question.answers %}
<div>
<button class="btn btn-outline-success"
type="submit"
name="answer"
value="{{ answer.id }}">{{ answer.text }}</button>
</div>
{% if season.settings.confirmAnswers == false %}
{% for answer in question.answers %}
<div class="py-2">
<button class="btn btn-outline-success"
type="submit"
name="answer"
value="{{ answer.id }}">{{ answer.text }}</button>
</div>
{% endfor %}
{% else %}
Weirdly enough this question has no answers...
{% endfor %}
{% for answer in question.answers %}
<div class="py-1">
<input type="radio" class="btn-check" name="answer" id="answer-{{ loop.index0 }}" autocomplete="off"
value="{{ answer.id }}">
<label class="btn btn-outline-secondary" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
</div>
{% endfor %}
<div class="py-2">
<button class="btn btn-success"
type="submit"
>{{ 'Next'|trans }}</button>
</div>
{% endif %}
</form>
{% endblock body %}

View File

@@ -33,6 +33,14 @@
<source>Already have an account? Log in</source>
<target>Heb je al een account? Log in</target>
</trans-unit>
<trans-unit id="Qu1euq_" resname="Are you sure you want to clear all the results? This will also delete al the eliminations.">
<source>Are you sure you want to clear all the results? This will also delete al the eliminations.</source>
<target>Weet je zeker datatype je de resultaten will leegmaken? Dit gooit ook alle eliminaties weg.</target>
</trans-unit>
<trans-unit id="Ec4twG8" resname="Are you sure you want to delete this quiz?">
<source>Are you sure you want to delete this quiz?</source>
<target>Weet je zeker datatype je deze test will verwijderen?</target>
</trans-unit>
<trans-unit id=".QFPbFe" resname="Back">
<source>Back</source>
<target>Terug</target>
@@ -89,6 +97,10 @@
<source>Download Template</source>
<target>Download sjabloon</target>
</trans-unit>
<trans-unit id="FfYlwX8" resname="EMPTY">
<source>EMPTY</source>
<target>LEEG</target>
</trans-unit>
<trans-unit id="JZi_tm0" resname="Email">
<source>Email</source>
<target>E-mail</target>
@@ -137,6 +149,14 @@
<source>Name</source>
<target>Naam</target>
</trans-unit>
<trans-unit id="wd1MvZW" resname="No">
<source>No</source>
<target>Nee</target>
</trans-unit>
<trans-unit id="gefhnBC" resname="Next">
<source>Next</source>
<target>Volgende</target>
</trans-unit>
<trans-unit id="nOHriCl" resname="No active quiz">
<source>No active quiz</source>
<target>Geen actieve test</target>
@@ -157,9 +177,13 @@
<source>Password</source>
<target>Wachtwoord</target>
</trans-unit>
<trans-unit id="VbgD9L8" resname="Please Confirm">
<source>Please Confirm</source>
<target>Bevestig Alsjeblieft</target>
</trans-unit>
<trans-unit id="6EclFME" resname="Please Confirm your Email">
<source>Please Confirm your Email</source>
<target>messages</target>
<target>Bevestig je e-mailadres alsjeblieft</target>
</trans-unit>
<trans-unit id="lSX_PHJ" resname="Please sign in">
<source>Please sign in</source>
@@ -253,6 +277,10 @@
<source>Seasons</source>
<target>Seizoenen</target>
</trans-unit>
<trans-unit id="VXFwlwn" resname="Settings">
<source>Settings</source>
<target>Instellingen</target>
</trans-unit>
<trans-unit id="pNIxNSX" resname="Sign in">
<source>Sign in</source>
<target>Log in</target>
@@ -277,6 +305,10 @@
<source>Time</source>
<target>Tijd</target>
</trans-unit>
<trans-unit id="pRCwpOT" resname="Yes">
<source>Yes</source>
<target>Ja</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>