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'])); + } }