diff --git a/.idea/TijdVoorDeTest.iml b/.idea/TijdVoorDeTest.iml
index a5a7e22..f0ec1db 100644
--- a/.idea/TijdVoorDeTest.iml
+++ b/.idea/TijdVoorDeTest.iml
@@ -167,6 +167,8 @@
+
+
diff --git a/.idea/php.xml b/.idea/php.xml
index 9fac556..3261d69 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -198,6 +198,8 @@
+
+
diff --git a/composer.json b/composer.json
index f3b513a..598566f 100644
--- a/composer.json
+++ b/composer.json
@@ -19,6 +19,7 @@
"phpstan/phpdoc-parser": "^2.3",
"runtime/frankenphp-symfony": "^0.2.0",
"sentry/sentry-symfony": "^5.6",
+ "stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/brevo-mailer": "7.3.*",
diff --git a/composer.lock b/composer.lock
index c6b85af..ae599f0 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "0ad053f4679a6b7cb76699c8391a12e5",
+ "content-hash": "944e218741f3c7a1f04072e075255c2c",
"packages": [
{
"name": "composer/pcre",
@@ -1338,6 +1338,138 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
+ {
+ "name": "gedmo/doctrine-extensions",
+ "version": "v3.21.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine-extensions/DoctrineExtensions.git",
+ "reference": "eb53dfcb2b592327b76ac5226fbb003d32aea37e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/eb53dfcb2b592327b76ac5226fbb003d32aea37e",
+ "reference": "eb53dfcb2b592327b76ac5226fbb003d32aea37e",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/collections": "^1.2 || ^2.0",
+ "doctrine/deprecations": "^1.0",
+ "doctrine/event-manager": "^1.2 || ^2.0",
+ "doctrine/persistence": "^2.2 || ^3.0 || ^4.0",
+ "php": "^7.4 || ^8.0",
+ "psr/cache": "^1 || ^2 || ^3",
+ "psr/clock": "^1",
+ "symfony/cache": "^5.4 || ^6.0 || ^7.0",
+ "symfony/string": "^5.4 || ^6.0 || ^7.0"
+ },
+ "conflict": {
+ "behat/transliterator": "<1.2 || >=2.0",
+ "doctrine/annotations": "<1.13 || >=3.0",
+ "doctrine/common": "<2.13 || >=4.0",
+ "doctrine/dbal": "<3.7 || >=5.0",
+ "doctrine/mongodb-odm": "<2.3 || >=3.0",
+ "doctrine/orm": "<2.20 || >=3.0 <3.3 || >=4.0"
+ },
+ "require-dev": {
+ "behat/transliterator": "^1.2",
+ "doctrine/annotations": "^1.13 || ^2.0",
+ "doctrine/cache": "^1.11 || ^2.0",
+ "doctrine/common": "^2.13 || ^3.0",
+ "doctrine/dbal": "^3.7 || ^4.0",
+ "doctrine/doctrine-bundle": "^2.3",
+ "doctrine/mongodb-odm": "^2.3",
+ "doctrine/orm": "^2.20 || ^3.3",
+ "friendsofphp/php-cs-fixer": "^3.70",
+ "nesbot/carbon": "^2.71 || ^3.0",
+ "phpstan/phpstan": "^2.1.1",
+ "phpstan/phpstan-doctrine": "^2.0.1",
+ "phpstan/phpstan-phpunit": "^2.0.3",
+ "phpunit/phpunit": "^9.6",
+ "rector/rector": "^2.0.6",
+ "symfony/console": "^5.4 || ^6.0 || ^7.0",
+ "symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0",
+ "symfony/phpunit-bridge": "^6.4 || ^7.0",
+ "symfony/uid": "^5.4 || ^6.0 || ^7.0",
+ "symfony/yaml": "^5.4 || ^6.0 || ^7.0"
+ },
+ "suggest": {
+ "doctrine/mongodb-odm": "to use the extensions with the MongoDB ODM",
+ "doctrine/orm": "to use the extensions with the ORM"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Gedmo\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gediminas Morkevicius",
+ "email": "gediminas.morkevicius@gmail.com"
+ },
+ {
+ "name": "Gustavo Falco",
+ "email": "comfortablynumb84@gmail.com"
+ },
+ {
+ "name": "David Buchmann",
+ "email": "david@liip.ch"
+ }
+ ],
+ "description": "Doctrine behavioral extensions",
+ "homepage": "http://gediminasm.org/",
+ "keywords": [
+ "Blameable",
+ "behaviors",
+ "doctrine",
+ "extensions",
+ "gedmo",
+ "loggable",
+ "nestedset",
+ "odm",
+ "orm",
+ "sluggable",
+ "sortable",
+ "timestampable",
+ "translatable",
+ "tree",
+ "uploadable"
+ ],
+ "support": {
+ "docs": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc",
+ "issues": "https://github.com/doctrine-extensions/DoctrineExtensions/issues",
+ "source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.21.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/l3pp4rd",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/mbabker",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phansys",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/stof",
+ "type": "github"
+ }
+ ],
+ "time": "2025-09-22T17:04:34+00:00"
+ },
{
"name": "guzzlehttp/psr7",
"version": "2.8.0",
@@ -2858,6 +2990,87 @@
],
"time": "2025-09-24T13:41:01+00:00"
},
+ {
+ "name": "stof/doctrine-extensions-bundle",
+ "version": "v1.14.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/stof/StofDoctrineExtensionsBundle.git",
+ "reference": "bdf3eb10baeb497ac5985b8f78a6cf55862c2662"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/stof/StofDoctrineExtensionsBundle/zipball/bdf3eb10baeb497ac5985b8f78a6cf55862c2662",
+ "reference": "bdf3eb10baeb497ac5985b8f78a6cf55862c2662",
+ "shasum": ""
+ },
+ "require": {
+ "gedmo/doctrine-extensions": "^3.20.0",
+ "php": "^8.1",
+ "symfony/cache": "^6.4 || ^7.0",
+ "symfony/config": "^6.4 || ^7.0",
+ "symfony/dependency-injection": "^6.4 || ^7.0",
+ "symfony/event-dispatcher": "^6.4 || ^7.0",
+ "symfony/http-kernel": "^6.4 || ^7.0",
+ "symfony/translation-contracts": "^2.5 || ^3.5"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpstan/phpstan-symfony": "^2.0",
+ "symfony/mime": "^6.4 || ^7.0",
+ "symfony/phpunit-bridge": "^v6.4.1 || ^7.0.1",
+ "symfony/security-core": "^6.4 || ^7.0"
+ },
+ "suggest": {
+ "doctrine/doctrine-bundle": "to use the ORM extensions",
+ "doctrine/mongodb-odm-bundle": "to use the MongoDB ODM extensions",
+ "symfony/mime": "To use the Mime component integration for Uploadable"
+ },
+ "type": "symfony-bundle",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Stof\\DoctrineExtensionsBundle\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christophe Coevoet",
+ "email": "stof@notk.org"
+ }
+ ],
+ "description": "Integration of the gedmo/doctrine-extensions with Symfony",
+ "homepage": "https://github.com/stof/StofDoctrineExtensionsBundle",
+ "keywords": [
+ "behaviors",
+ "doctrine2",
+ "extensions",
+ "gedmo",
+ "loggable",
+ "nestedset",
+ "sluggable",
+ "sortable",
+ "timestampable",
+ "translatable",
+ "tree"
+ ],
+ "support": {
+ "issues": "https://github.com/stof/StofDoctrineExtensionsBundle/issues",
+ "source": "https://github.com/stof/StofDoctrineExtensionsBundle/tree/v1.14.0"
+ },
+ "time": "2025-05-01T08:00:32+00:00"
+ },
{
"name": "symfony/asset",
"version": "v7.3.0",
diff --git a/config/bundles.php b/config/bundles.php
index 5c150fe..0cf9639 100644
--- a/config/bundles.php
+++ b/config/bundles.php
@@ -7,6 +7,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Sentry\SentryBundle\SentryBundle;
+use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MakerBundle\MakerBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
@@ -34,4 +35,5 @@ return [
StimulusBundle::class => ['all' => true],
TurboBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true],
+ StofDoctrineExtensionsBundle::class => ['all' => true],
];
diff --git a/config/packages/stof_doctrine_extensions.yaml b/config/packages/stof_doctrine_extensions.yaml
new file mode 100644
index 0000000..9718713
--- /dev/null
+++ b/config/packages/stof_doctrine_extensions.yaml
@@ -0,0 +1,7 @@
+# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
+# See the official DoctrineExtensions documentation for more details: https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc
+stof_doctrine_extensions:
+ default_locale: nl
+ orm:
+ default:
+ timestampable: true
diff --git a/config/preload.php b/config/preload.php
index 7cbe578..b5e3bb0 100644
--- a/config/preload.php
+++ b/config/preload.php
@@ -2,6 +2,6 @@
declare(strict_types=1);
-if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
- require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
+if (file_exists(dirname(__DIR__).'/var/cache/prod/Tvdt_KernelProdContainer.preload.php')) {
+ require dirname(__DIR__).'/var/cache/prod/Tvdt_KernelProdContainer.preload.php';
}
diff --git a/src/Controller/QuizController.php b/src/Controller/QuizController.php
index de29045..49db665 100644
--- a/src/Controller/QuizController.php
+++ b/src/Controller/QuizController.php
@@ -119,7 +119,7 @@ final class QuizController extends AbstractController
// TODO: Extract getting next question logic to a service
$question = $questionRepository->findNextQuestionForCandidate($candidate);
- // Keep creating flash here based on return type of service call
+ // Keep creating flash here based on the return type of service call
if (!$question instanceof Question) {
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz completed'));
diff --git a/src/Entity/Elimination.php b/src/Entity/Elimination.php
index 8875a3a..20b6495 100644
--- a/src/Entity/Elimination.php
+++ b/src/Entity/Elimination.php
@@ -6,7 +6,7 @@ namespace Tvdt\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
-use Safe\DateTimeImmutable;
+use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\HttpFoundation\InputBag;
use Symfony\Component\Uid\Uuid;
@@ -30,6 +30,7 @@ class Elimination
#[ORM\Column(type: Types::JSONB)]
public array $data = [];
+ #[Gedmo\Timestampable(on: 'create')]
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)]
public private(set) \DateTimeImmutable $created;
@@ -56,10 +57,4 @@ class Elimination
{
return $this->data[$name] ?? null;
}
-
- #[ORM\PrePersist]
- public function setCreatedAtValue(): void
- {
- $this->created = new DateTimeImmutable();
- }
}
diff --git a/src/Entity/GivenAnswer.php b/src/Entity/GivenAnswer.php
index 549f1fb..fb5edea 100644
--- a/src/Entity/GivenAnswer.php
+++ b/src/Entity/GivenAnswer.php
@@ -6,13 +6,12 @@ namespace Tvdt\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
-use Safe\DateTimeImmutable;
+use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
use Tvdt\Repository\GivenAnswerRepository;
#[ORM\Entity(repositoryClass: GivenAnswerRepository::class)]
-#[ORM\HasLifecycleCallbacks]
class GivenAnswer
{
#[ORM\Column(type: UuidType::NAME, unique: true)]
@@ -21,6 +20,7 @@ class GivenAnswer
#[ORM\Id]
public private(set) Uuid $id;
+ #[Gedmo\Timestampable(on: 'create')]
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)]
public private(set) \DateTimeImmutable $created;
@@ -37,10 +37,4 @@ class GivenAnswer
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]
private(set) Answer $answer,
) {}
-
- #[ORM\PrePersist]
- public function setCreatedAtValue(): void
- {
- $this->created = new DateTimeImmutable();
- }
}
diff --git a/src/Entity/QuizCandidate.php b/src/Entity/QuizCandidate.php
index ca104bb..c3054d9 100644
--- a/src/Entity/QuizCandidate.php
+++ b/src/Entity/QuizCandidate.php
@@ -6,13 +6,12 @@ namespace Tvdt\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
-use Safe\DateTimeImmutable;
+use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
use Tvdt\Repository\QuizCandidateRepository;
#[ORM\Entity(repositoryClass: QuizCandidateRepository::class)]
-#[ORM\HasLifecycleCallbacks]
#[ORM\UniqueConstraint(columns: ['candidate_id', 'quiz_id'])]
class QuizCandidate
{
@@ -25,6 +24,7 @@ class QuizCandidate
#[ORM\Column]
public float $corrections = 0;
+ #[Gedmo\Timestampable(on: 'create')]
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
public private(set) \DateTimeImmutable $created;
@@ -37,10 +37,4 @@ class QuizCandidate
#[ORM\ManyToOne(inversedBy: 'quizData')]
public Candidate $candidate,
) {}
-
- #[ORM\PrePersist]
- public function setCreatedAtValue(): void
- {
- $this->created = new DateTimeImmutable();
- }
}
diff --git a/src/Security/Voter/SeasonVoter.php b/src/Security/Voter/SeasonVoter.php
index 16fb8e4..85fa9e2 100644
--- a/src/Security/Voter/SeasonVoter.php
+++ b/src/Security/Voter/SeasonVoter.php
@@ -48,24 +48,14 @@ final class SeasonVoter extends Voter
return true;
}
- switch (true) {
- case $subject instanceof Answer:
- $season = $subject->question->quiz->season;
- break;
- case $subject instanceof Elimination:
- case $subject instanceof Question:
- $season = $subject->quiz->season;
- break;
- case $subject instanceof Candidate:
- case $subject instanceof Quiz:
- $season = $subject->season;
- break;
- case $subject instanceof Season:
- $season = $subject;
- break;
- default:
- return false;
- }
+ $season = match (true) {
+ $subject instanceof Answer => $subject->question->quiz->season,
+ $subject instanceof Elimination,
+ $subject instanceof Question => $subject->quiz->season,
+ $subject instanceof Candidate,
+ $subject instanceof Quiz => $subject->season,
+ $subject instanceof Season => $subject,
+ };
return match ($attribute) {
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
diff --git a/src/Service/QuizSpreadsheetService.php b/src/Service/QuizSpreadsheetService.php
index 8aa951c..7a1178d 100644
--- a/src/Service/QuizSpreadsheetService.php
+++ b/src/Service/QuizSpreadsheetService.php
@@ -55,6 +55,10 @@ class QuizSpreadsheetService
/** @throws SpreadsheetDataException */
public function xlsxToQuiz(Quiz $quiz, File $file): void
{
+ if (!$this->isSpreadsheetFile($file)) {
+ throw new \InvalidArgumentException('File must be a valid XLSX spreadsheet');
+ }
+
$spreadsheet = $this->readSheet($file);
$sheet = $spreadsheet->getSheet($spreadsheet->getFirstSheetIndex());
@@ -112,7 +116,10 @@ class QuizSpreadsheetService
}
}
- public function quizToXlsx(Quiz $quiz): void {}
+ public function quizToXlsx(Quiz $quiz): void
+ {
+ throw new \Exception('Not implemented');
+ }
private function toXlsx(Spreadsheet $spreadsheet): \Closure
{
@@ -120,4 +127,9 @@ class QuizSpreadsheetService
return static fn () => $writer->save('php://output');
}
+
+ private function isSpreadsheetFile(File $file): bool
+ {
+ return 'xlsx' === $file->getExtension();
+ }
}
diff --git a/symfony.lock b/symfony.lock
index 0daf6ab..89cdaf4 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -109,6 +109,18 @@
"config/packages/sentry.yaml"
]
},
+ "stof/doctrine-extensions-bundle": {
+ "version": "1.14",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "1.2",
+ "ref": "e805aba9eff5372e2d149a9ff56566769e22819d"
+ },
+ "files": [
+ "config/packages/stof_doctrine_extensions.yaml"
+ ]
+ },
"symfony/asset-mapper": {
"version": "7.2",
"recipe": {
diff --git a/tests/Security/Voter/SeasonVoterTest.php b/tests/Security/Voter/SeasonVoterTest.php
index eeca45b..2941091 100644
--- a/tests/Security/Voter/SeasonVoterTest.php
+++ b/tests/Security/Voter/SeasonVoterTest.php
@@ -10,6 +10,7 @@ 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;
+use Symfony\Component\Security\Core\User\UserInterface;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Elimination;
@@ -53,27 +54,57 @@ final class SeasonVoterTest extends TestCase
{
$season = self::createStub(Season::class);
$season->method('isOwner')->willReturn(true);
-
- $quiz = self::createStub(Quiz::class);
- $quiz->season = $season;
-
- $elimination = self::createStub(Elimination::class);
- $elimination->quiz = $quiz;
+ yield 'Season' => [$season];
$candidate = self::createStub(Candidate::class);
$candidate->season = $season;
+ yield 'Candidate' => [$candidate];
+
+ $quiz = self::createStub(Quiz::class);
+ $quiz->season = $season;
+ yield 'Quiz' => [$quiz];
+
+ $elimination = self::createStub(Elimination::class);
+ $elimination->quiz = $quiz;
+ yield 'Elimination' => [$elimination];
$question = self::createStub(Question::class);
$question->quiz = $quiz;
+ yield 'Question' => [$question];
$answer = self::createStub(Answer::class);
$answer->question = $question;
-
- yield 'Season' => [$season];
- yield 'Elimination' => [$elimination];
- yield 'Quiz' => [$quiz];
- yield 'Candidate' => [$candidate];
- yield 'Question' => [$question];
yield 'Answer' => [$answer];
}
+
+ public function testWrongUserTypeReturnFalse(): void
+ {
+ $user = self::createStub(UserInterface::class);
+ $token = $this->createStub(TokenInterface::class);
+ $token->method('getUser')->willReturn($user);
+
+ $this->assertSame(VoterInterface::ACCESS_DENIED, $this->seasonVoter->vote($token, new Season(), ['SEASON_EDIT']));
+ }
+
+ public function testAdminCanDoAnything(): void
+ {
+ $user = new User();
+ $user->roles = ['ROLE_ADMIN'];
+ $token = $this->createStub(TokenInterface::class);
+ $token->method('getUser')->willReturn($user);
+
+ $this->assertSame(VoterInterface::ACCESS_GRANTED, $this->seasonVoter->vote($token, new Season(), ['SEASON_EDIT']));
+ }
+
+ public function testRandomClassWillAbstain(): void
+ {
+ $subject = new \stdClass();
+ $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $this->seasonVoter->vote($this->token, $subject, ['SEASON_EDIT']));
+ }
+
+ public function testRandomSunjectWillAbstain(): void
+ {
+ $subject = new Season();
+ $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $this->seasonVoter->vote($this->token, $subject, ['DO_NOTHING']));
+ }
}