From 18a609036604186b1c04bc602f79918ccb87f6eb Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Sun, 22 Mar 2026 22:40:25 +0100 Subject: [PATCH] Answer on candidate (#72) * Add Penalty Seconds on tests * Refactors and start of candidate answer relation * Add breadcrumbs and UI consistency updates across backoffice templates * Add breadcrumbs and UI consistency updates across backoffice templates * Add Dutch translations for email verification and security messages * Rector * Refactor for code consistency and type safety assertions across repositories and entities * Refactor candidate-related logic to optimize queries, improve template separation, and add "Answer Mapping" functionality. * Cleanup * Update Symfony * Add coderabbit config * Fixes from coderabbit --- .coderabbit.yaml | 6 + .idea/dataSources.xml | 5 + AGENTS.md | 69 ++ Justfile | 5 +- assets/backoffice.js | 7 +- assets/bootstrap.js | 6 +- assets/controllers.json | 2 +- assets/controllers/bo/quiz_controller.js | 21 +- assets/controllers/elimination_controller.js | 12 +- assets/quiz.js | 8 +- assets/stimulus.js | 3 + assets/stimulus_bootstrap.js | 2 - assets/styles/{app.scss => quiz.scss} | 0 compose.build.yaml | 1 + compose.override.yaml | 1 + compose.prod.yaml | 1 + compose.yaml | 1 + composer.json | 4 +- composer.lock | 501 ++++++------- config/packages/maker.yaml | 6 + config/packages/security.yaml | 10 - config/reference.php | 75 +- importmap.php | 12 +- migrations/Version20260125191247.php | 31 + migrations/Version20260309215703.php | 31 + migrations/Version20260309220448.php | 34 + phpstan.dist.neon | 2 + src/Controller/Backoffice/QuizController.php | 242 ++++++- src/Controller/QuizController.php | 17 +- src/Dto/Result.php | 1 + src/Entity/Answer.php | 7 +- src/Entity/Question.php | 20 +- src/Entity/Quiz.php | 111 +++ src/Entity/QuizCandidate.php | 9 + src/Entity/Season.php | 2 + src/Form/SettingsForm.php | 6 +- src/Repository/QuizCandidateRepository.php | 32 +- src/Repository/QuizRepository.php | 83 ++- templates/backoffice/base.html.twig | 13 + templates/backoffice/index.html.twig | 14 +- .../prepare_elimination/index.html.twig | 31 +- templates/backoffice/quiz.html.twig | 188 +---- .../backoffice/quiz/tab_candidates.html.twig | 66 ++ .../quiz/tab_candidates_list.html.twig | 50 ++ .../backoffice/quiz/tab_overview.html.twig | 105 +++ .../backoffice/quiz/tab_result.html.twig | 77 ++ templates/backoffice/quiz_add.html.twig | 18 +- templates/backoffice/season.html.twig | 30 +- templates/backoffice/season_add.html.twig | 15 +- .../season_add_candidates.html.twig | 16 +- templates/base.html.twig | 3 +- tests/Repository/QuizRepositoryTest.php | 7 + translations/VerifyEmailBundle.nl.xliff | 42 ++ translations/messages+intl-icu.nl.xliff | 142 +++- translations/security.nl.xliff | 86 +++ translations/validators.nl.xliff | 680 ++++++++++++++++++ 56 files changed, 2389 insertions(+), 580 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 AGENTS.md create mode 100644 assets/stimulus.js delete mode 100644 assets/stimulus_bootstrap.js rename assets/styles/{app.scss => quiz.scss} (100%) create mode 100644 config/packages/maker.yaml create mode 100644 migrations/Version20260125191247.php create mode 100644 migrations/Version20260309215703.php create mode 100644 migrations/Version20260309220448.php create mode 100644 templates/backoffice/quiz/tab_candidates.html.twig create mode 100644 templates/backoffice/quiz/tab_candidates_list.html.twig create mode 100644 templates/backoffice/quiz/tab_overview.html.twig create mode 100644 templates/backoffice/quiz/tab_result.html.twig create mode 100644 translations/VerifyEmailBundle.nl.xliff create mode 100644 translations/security.nl.xliff diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..60c53d4 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-GB" +reviews: + sequence_diagrams: false + path_filters: + - "!config/reference.php" diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 5c91296..3fe6a75 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -6,6 +6,11 @@ true org.postgresql.Driver jdbc:postgresql://localhost:5432/app + + + + + $ProjectFileDir$ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1c9bcd0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# Agent Guide: Tijd Voor De Test (Tvdt) + +This document provides essential context and instructions for AI agents working on the **Tijd Voor De Test** project. + +## Project Overview +A web application for managing "Wie is de Mol?" style tests, including seasons, quizzes, candidates, and eliminations. + +- **Namespace**: `Tvdt` +- **PHP Version**: 8.5+ +- **Framework**: Symfony 8.0 + +## Tech Stack +- **Server**: FrankenPHP (Caddy-based PHP server) +- **Database**: PostgreSQL +- **Frontend**: Symfony Asset Mapper (no Node.js/Webpack), Stimulus, Turbo +- **Styling**: Sass (via `symfonycasts/sass-bundle`) +- **Persistence**: Doctrine ORM 3.x + +## Core Domain Entities +- **Season**: Groups quizzes and candidates for a specific period. +- **SeasonSettings**: Configuration for a season. +- **Quiz**: A test within a season containing multiple questions. +- **Question**: Questions belonging to a quiz. +- **Answer**: Possible answers for a question. +- **Candidate**: A participant in the season. +- **QuizCandidate**: Represents a candidate's attempt at a specific quiz (tracking start/end time). +- **GivenAnswer**: The specific answer a candidate selected during a quiz. +- **Elimination**: Records of red/green screens and forced results. +- **User**: Administrative accounts for managing the system. + +## Development Workflow +The project uses `just` as the primary task runner. Always prefer `just` commands over manual docker calls. + +### Common Commands +- `just up`: Start the environment. +- `just down`: Stop the environment. +- `just shell`: Enter the PHP container. +- `just migrate`: Run database migrations. +- `just fixtures`: Load development fixtures. +- `just fix-cs`: Run `php-cs-fixer` and `twig-cs-fixer`. +- `just phpstan`: Run static analysis. +- `just rector`: Run Rector for automated refactorings. +- `just reload-tests`: Reset the test database and load test fixtures. + +## Coding Standards +- **PSR-12**: Follow standard PHP coding styles. +- **Strict Typing**: Use strict types in all PHP files. +- **Doctrine ORM 3**: Be aware of ORM 3 changes (e.g., lazy loading behavior, attribute-based mapping). +- **Symfony 8**: Use modern Symfony features (Attributes, Type-hinting). +- **Safe Functions**: Use `thecodingmachine/safe` for standard PHP functions that throw exceptions instead of returning false. + +## Testing +- **Framework**: PHPUnit +- **Bundle**: `dama/doctrine-test-bundle` is used to wrap tests in transactions. +- **Location**: `tests/` directory mirroring `src/`. +- **Execution**: Run via `bin/phpunit` inside the container or `just reload-tests` to prepare the environment. + +## Frontend Development +- JavaScript is managed via **Import Maps**. +- Stimulus controllers are located in `assets/controllers/`. +- CSS/Sass is in `assets/styles/`. +- Assets are compiled on-the-fly or mapped; do not look for a `node_modules` folder. + +## Key Files +- `composer.json`: Dependency management. +- `importmap.php`: JavaScript module mapping. +- `Justfile`: Automation shortcuts. +- `config/`: Application configuration. +- `templates/`: Twig templates. diff --git a/Justfile b/Justfile index ad99996..7d23c11 100644 --- a/Justfile +++ b/Justfile @@ -23,7 +23,7 @@ fixtures: docker compose exec php bin/console doctrine:fixtures:load --purge-with-truncate --no-interaction --group=dev translations: - docker compose exec php bin/console translation:extract --force --format=xliff --sort=asc --clean nl + docker compose exec php bin/console translation:extract --force --format=xliff --sort=asc nl fix-cs: docker compose exec php vendor/bin/php-cs-fixer fix @@ -35,6 +35,9 @@ rector *args: phpstan *args: docker compose exec php vendor/bin/phpstan analyse {{ args }} +test *args: + docker compose exec php vendor/bin/phpunit {{ args }} + [confirm] clean: docker compose down -v --remove-orphans diff --git a/assets/backoffice.js b/assets/backoffice.js index 0729569..3690b45 100644 --- a/assets/backoffice.js +++ b/assets/backoffice.js @@ -1,5 +1,4 @@ -import * as bootstrap from 'bootstrap' -import './bootstrap.js'; -import 'bootstrap/dist/css/bootstrap.min.css' - +import 'bootstrap/dist/css/bootstrap.min.css'; import './styles/backoffice.scss'; +import './stimulus.js'; +import './bootstrap.js'; diff --git a/assets/bootstrap.js b/assets/bootstrap.js index d4e50c9..786b2ec 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -1,5 +1 @@ -import { startStimulusApp } from '@symfony/stimulus-bundle'; - -const app = startStimulusApp(); -// register any custom, 3rd party controllers here -// app.register('some_controller_name', SomeImportedController); +import * as bootstrap from 'bootstrap' diff --git a/assets/controllers.json b/assets/controllers.json index 2230c71..29ea244 100644 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -2,7 +2,7 @@ "controllers": { "@symfony/ux-turbo": { "turbo-core": { - "enabled": false, + "enabled": true, "fetch": "eager" }, "mercure-turbo-stream": { diff --git a/assets/controllers/bo/quiz_controller.js b/assets/controllers/bo/quiz_controller.js index 99f8412..3db64f3 100644 --- a/assets/controllers/bo/quiz_controller.js +++ b/assets/controllers/bo/quiz_controller.js @@ -1,17 +1,28 @@ import {Controller} from '@hotwired/stimulus'; -import * as bootstrap from 'bootstrap' +import {Tooltip, Modal} from 'bootstrap'; export default class extends Controller { + static targets = ['clearModal', 'deleteModal']; + connect() { - const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') - const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)) + this.tooltips = []; + const tooltipTriggerList = this.element.querySelectorAll('[data-bs-toggle="tooltip"]'); + [...tooltipTriggerList].forEach(tooltipTriggerEl => { + this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl)); + }); + } + + disconnect() { + this.tooltips.forEach(tooltip => tooltip.dispose()); } clearQuiz() { - new bootstrap.Modal('#clearQuizModal').show(); + const modal = Modal.getOrCreateInstance(this.clearModalTarget); + modal.show(); } deleteQuiz() { - new bootstrap.Modal('#deleteQuizModal').show(); + const modal = Modal.getOrCreateInstance(this.deleteModalTarget); + modal.show(); } } diff --git a/assets/controllers/elimination_controller.js b/assets/controllers/elimination_controller.js index 6550bee..9965206 100644 --- a/assets/controllers/elimination_controller.js +++ b/assets/controllers/elimination_controller.js @@ -2,9 +2,13 @@ import {Controller} from '@hotwired/stimulus'; export default class extends Controller { next() { - const currentUrl = window.location.href; - const urlParts = currentUrl.split('/'); - urlParts.pop(); - window.location.href = urlParts.join('/'); + const currentUrl = new URL(window.location.href); + const pathParts = currentUrl.pathname.split('/'); + // Remove the last segment + pathParts.pop(); + // Update the pathname + currentUrl.pathname = pathParts.join('/'); + // Navigate + window.location.href = currentUrl.href; } } diff --git a/assets/quiz.js b/assets/quiz.js index b9160a7..fc0b939 100644 --- a/assets/quiz.js +++ b/assets/quiz.js @@ -1,6 +1,4 @@ +import 'bootstrap/dist/css/bootstrap.min.css'; +import './styles/quiz.scss'; +import './stimulus.js'; import './bootstrap.js'; -import 'bootstrap/dist/css/bootstrap.min.css' -import * as bootstrap from 'bootstrap' - -import './styles/app.scss' - diff --git a/assets/stimulus.js b/assets/stimulus.js new file mode 100644 index 0000000..b22f20c --- /dev/null +++ b/assets/stimulus.js @@ -0,0 +1,3 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); diff --git a/assets/stimulus_bootstrap.js b/assets/stimulus_bootstrap.js deleted file mode 100644 index 2689398..0000000 --- a/assets/stimulus_bootstrap.js +++ /dev/null @@ -1,2 +0,0 @@ -// register any custom, 3rd party controllers here -// app.register('some_controller_name', SomeImportedController); diff --git a/assets/styles/app.scss b/assets/styles/quiz.scss similarity index 100% rename from assets/styles/app.scss rename to assets/styles/quiz.scss diff --git a/compose.build.yaml b/compose.build.yaml index c595020..78ebb79 100644 --- a/compose.build.yaml +++ b/compose.build.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-go/master/schema/compose-spec.json services: php: build: diff --git a/compose.override.yaml b/compose.override.yaml index 65cb06d..df3e622 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-go/master/schema/compose-spec.json # Development environment override services: php: diff --git a/compose.prod.yaml b/compose.prod.yaml index 9d9d729..af51704 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-go/master/schema/compose-spec.json # Production environment override services: php: diff --git a/compose.yaml b/compose.yaml index 4e82753..a9b8fce 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-go/master/schema/compose-spec.json services: php: image: ${IMAGES_PREFIX:-}app-php diff --git a/composer.json b/composer.json index a9d7a3d..61fc6d9 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "symfony/translation": "8.0.*", "symfony/twig-bundle": "8.0.*", "symfony/uid": "8.0.*", - "symfony/ux-turbo": "^2.32.0", + "symfony/ux-turbo": "^2.33.0", "symfony/validator": "8.0.*", "symfony/yaml": "8.0.*", "symfonycasts/sass-bundle": "^0.8.3", @@ -61,7 +61,7 @@ "roave/security-advisories": "dev-latest", "symfony/browser-kit": "8.0.*", "symfony/css-selector": "8.0.*", - "symfony/maker-bundle": "^1.65.1", + "symfony/maker-bundle": "^1.67.0", "symfony/phpunit-bridge": "8.0.*", "symfony/stopwatch": "8.0.*", "symfony/web-profiler-bundle": "8.0.*", diff --git a/composer.lock b/composer.lock index 92e7fdc..39560fb 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": "2017dcdd0be77c2466ed61e8bf9e4097", + "content-hash": "3e410edfc3f2479fa379f9ae096d743b", "packages": [ { "name": "composer/pcre", @@ -2971,16 +2971,16 @@ }, { "name": "symfony/asset", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "2401c7e9f223969f0979eeb884a09fa6f8d7e49b" + "reference": "e32d8441a7d5dd8db159fd71501bd11ff269b5a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/2401c7e9f223969f0979eeb884a09fa6f8d7e49b", - "reference": "2401c7e9f223969f0979eeb884a09fa6f8d7e49b", + "url": "https://api.github.com/repos/symfony/asset/zipball/e32d8441a7d5dd8db159fd71501bd11ff269b5a4", + "reference": "e32d8441a7d5dd8db159fd71501bd11ff269b5a4", "shasum": "" }, "require": { @@ -3017,7 +3017,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v8.0.4" + "source": "https://github.com/symfony/asset/tree/v8.0.6" }, "funding": [ { @@ -3037,20 +3037,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/asset-mapper", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/asset-mapper.git", - "reference": "14184221c21c2622e62f2c009a6cc25c5570e4ba" + "reference": "80635c3722b9bb5481e0282497ae23796dcd3712" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/14184221c21c2622e62f2c009a6cc25c5570e4ba", - "reference": "14184221c21c2622e62f2c009a6cc25c5570e4ba", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/80635c3722b9bb5481e0282497ae23796dcd3712", + "reference": "80635c3722b9bb5481e0282497ae23796dcd3712", "shasum": "" }, "require": { @@ -3098,7 +3098,7 @@ "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset-mapper/tree/v8.0.4" + "source": "https://github.com/symfony/asset-mapper/tree/v8.0.6" }, "funding": [ { @@ -3118,7 +3118,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/brevo-mailer", @@ -3192,16 +3192,16 @@ }, { "name": "symfony/cache", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "92e9960386c7e01f58198038c199d522959a843c" + "reference": "b7b0f4ce5fb57a8dc061d494639e44e2cf7aa30f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/92e9960386c7e01f58198038c199d522959a843c", - "reference": "92e9960386c7e01f58198038c199d522959a843c", + "url": "https://api.github.com/repos/symfony/cache/zipball/b7b0f4ce5fb57a8dc061d494639e44e2cf7aa30f", + "reference": "b7b0f4ce5fb57a8dc061d494639e44e2cf7aa30f", "shasum": "" }, "require": { @@ -3268,7 +3268,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.0.5" + "source": "https://github.com/symfony/cache/tree/v8.0.7" }, "funding": [ { @@ -3288,7 +3288,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/cache-contracts", @@ -3445,16 +3445,16 @@ }, { "name": "symfony/config", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "8f45af92f08f82902827a8b6f403aaf49d893539" + "reference": "9a34c52187112503d02903ab35e6e3783f580c29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/8f45af92f08f82902827a8b6f403aaf49d893539", - "reference": "8f45af92f08f82902827a8b6f403aaf49d893539", + "url": "https://api.github.com/repos/symfony/config/zipball/9a34c52187112503d02903ab35e6e3783f580c29", + "reference": "9a34c52187112503d02903ab35e6e3783f580c29", "shasum": "" }, "require": { @@ -3499,7 +3499,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v8.0.4" + "source": "https://github.com/symfony/config/tree/v8.0.7" }, "funding": [ { @@ -3519,20 +3519,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/console", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", + "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", "shasum": "" }, "require": { @@ -3589,7 +3589,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.4" + "source": "https://github.com/symfony/console/tree/v8.0.7" }, "funding": [ { @@ -3609,20 +3609,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-03-06T14:06:22+00:00" }, { "name": "symfony/dependency-injection", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26" + "reference": "1faaac6dbfe069a92ab9e5c270fa43fc4e1761da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/40a6c455ade7e3bf25900d6b746d40cfa2573e26", - "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/1faaac6dbfe069a92ab9e5c270fa43fc4e1761da", + "reference": "1faaac6dbfe069a92ab9e5c270fa43fc4e1761da", "shasum": "" }, "require": { @@ -3670,7 +3670,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v8.0.5" + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.7" }, "funding": [ { @@ -3690,7 +3690,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-03-03T07:49:33+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3761,16 +3761,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "0d07589d03ed7db1833bfe943635872a2e8aebb2" + "reference": "649eec3f9cc806e42ee2e7928d05425ed66108d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/0d07589d03ed7db1833bfe943635872a2e8aebb2", - "reference": "0d07589d03ed7db1833bfe943635872a2e8aebb2", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/649eec3f9cc806e42ee2e7928d05425ed66108d4", + "reference": "649eec3f9cc806e42ee2e7928d05425ed66108d4", "shasum": "" }, "require": { @@ -3839,7 +3839,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.4" + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.7" }, "funding": [ { @@ -3859,20 +3859,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/dotenv", - "version": "v8.0.0", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "460b4067a85288c59a59ce8c1bfb3942e71fd85c" + "reference": "23bd13cf3f6cca8b7661548ef958ff4f4aa7c458" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/460b4067a85288c59a59ce8c1bfb3942e71fd85c", - "reference": "460b4067a85288c59a59ce8c1bfb3942e71fd85c", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/23bd13cf3f6cca8b7661548ef958ff4f4aa7c458", + "reference": "23bd13cf3f6cca8b7661548ef958ff4f4aa7c458", "shasum": "" }, "require": { @@ -3913,7 +3913,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v8.0.0" + "source": "https://github.com/symfony/dotenv/tree/v8.0.7" }, "funding": [ { @@ -3933,7 +3933,7 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:17:21+00:00" + "time": "2026-03-03T07:49:33+00:00" }, { "name": "symfony/error-handler", @@ -4179,16 +4179,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", "shasum": "" }, "require": { @@ -4225,7 +4225,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" }, "funding": [ { @@ -4245,20 +4245,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/finder", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", "shasum": "" }, "require": { @@ -4293,7 +4293,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.5" + "source": "https://github.com/symfony/finder/tree/v8.0.6" }, "funding": [ { @@ -4313,7 +4313,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-01-29T09:41:02+00:00" }, { "name": "symfony/flex", @@ -4390,16 +4390,16 @@ }, { "name": "symfony/form", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092" + "reference": "954e17b053dad9fb227ebd90260752e3a46bb06a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092", - "reference": "c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092", + "url": "https://api.github.com/repos/symfony/form/zipball/954e17b053dad9fb227ebd90260752e3a46bb06a", + "reference": "954e17b053dad9fb227ebd90260752e3a46bb06a", "shasum": "" }, "require": { @@ -4461,7 +4461,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v8.0.4" + "source": "https://github.com/symfony/form/tree/v8.0.7" }, "funding": [ { @@ -4481,20 +4481,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/framework-bundle", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "e2f9469e7a802dd7c0d193792afc494d68177c54" + "reference": "6a43d76538d52d4b7660f07054a07f8346f73eae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/e2f9469e7a802dd7c0d193792afc494d68177c54", - "reference": "e2f9469e7a802dd7c0d193792afc494d68177c54", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a43d76538d52d4b7660f07054a07f8346f73eae", + "reference": "6a43d76538d52d4b7660f07054a07f8346f73eae", "shasum": "" }, "require": { @@ -4517,7 +4517,7 @@ }, "conflict": { "doctrine/persistence": "<1.3", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/console": "<7.4", "symfony/form": "<7.4", @@ -4532,7 +4532,7 @@ "require-dev": { "doctrine/persistence": "^1.3|^2|^3", "dragonmantank/cron-expression": "^3.1", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", "symfony/asset": "^7.4|^8.0", @@ -4601,7 +4601,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.0.5" + "source": "https://github.com/symfony/framework-bundle/tree/v8.0.7" }, "funding": [ { @@ -4621,20 +4621,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:10+00:00" + "time": "2026-03-06T15:40:00+00:00" }, { "name": "symfony/http-client", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "f9fdd372473e66469c6d32a4ed12efcffdea38c4" + "reference": "ade9bd433450382f0af154661fc8e72758b4de36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/f9fdd372473e66469c6d32a4ed12efcffdea38c4", - "reference": "f9fdd372473e66469c6d32a4ed12efcffdea38c4", + "url": "https://api.github.com/repos/symfony/http-client/zipball/ade9bd433450382f0af154661fc8e72758b4de36", + "reference": "ade9bd433450382f0af154661fc8e72758b4de36", "shasum": "" }, "require": { @@ -4697,7 +4697,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.0.5" + "source": "https://github.com/symfony/http-client/tree/v8.0.7" }, "funding": [ { @@ -4717,7 +4717,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/http-client-contracts", @@ -4799,16 +4799,16 @@ }, { "name": "symfony/http-foundation", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb" + "reference": "c5ecf7b07408dbc4a87482634307654190954ae8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb", - "reference": "e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c5ecf7b07408dbc4a87482634307654190954ae8", + "reference": "c5ecf7b07408dbc4a87482634307654190954ae8", "shasum": "" }, "require": { @@ -4855,7 +4855,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.0.5" + "source": "https://github.com/symfony/http-foundation/tree/v8.0.7" }, "funding": [ { @@ -4875,20 +4875,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "20c1c5e41fc53928dbb670088f544f2d460d497d" + "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/20c1c5e41fc53928dbb670088f544f2d460d497d", - "reference": "20c1c5e41fc53928dbb670088f544f2d460d497d", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c04721f45723d8ce049fa3eee378b5a505272ac7", + "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7", "shasum": "" }, "require": { @@ -4959,7 +4959,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.5" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.7" }, "funding": [ { @@ -4979,20 +4979,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:46:31+00:00" + "time": "2026-03-06T16:58:46+00:00" }, { "name": "symfony/intl", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "8d049269c2accca0b02e5f9de39f3ee92ebc4468" + "reference": "4e14323828f51a293edbce15ca98d4f3dd927cbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/8d049269c2accca0b02e5f9de39f3ee92ebc4468", - "reference": "8d049269c2accca0b02e5f9de39f3ee92ebc4468", + "url": "https://api.github.com/repos/symfony/intl/zipball/4e14323828f51a293edbce15ca98d4f3dd927cbf", + "reference": "4e14323828f51a293edbce15ca98d4f3dd927cbf", "shasum": "" }, "require": { @@ -5048,7 +5048,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v8.0.4" + "source": "https://github.com/symfony/intl/tree/v8.0.6" }, "funding": [ { @@ -5068,20 +5068,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/mailer", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a074d353f5b5a81d356652e8a2034fdd0501420b" + "reference": "a8971c86b25ff8557e844f08c1f6207d9b3e614c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a074d353f5b5a81d356652e8a2034fdd0501420b", - "reference": "a074d353f5b5a81d356652e8a2034fdd0501420b", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a8971c86b25ff8557e844f08c1f6207d9b3e614c", + "reference": "a8971c86b25ff8557e844f08c1f6207d9b3e614c", "shasum": "" }, "require": { @@ -5128,7 +5128,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.0.4" + "source": "https://github.com/symfony/mailer/tree/v8.0.6" }, "funding": [ { @@ -5148,20 +5148,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:40:07+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/mime", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252" + "reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/543d01b6ee4b8eb80ce9349186ad530eb8704252", - "reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252", + "url": "https://api.github.com/repos/symfony/mime/zipball/5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b", + "reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b", "shasum": "" }, "require": { @@ -5171,13 +5171,13 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", @@ -5214,7 +5214,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.0.5" + "source": "https://github.com/symfony/mime/tree/v8.0.7" }, "funding": [ { @@ -5234,7 +5234,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:10+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/options-resolver", @@ -5309,16 +5309,16 @@ }, { "name": "symfony/password-hasher", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "ca6af4e20357d58d50c818d676cf2e2dd5e53b02" + "reference": "ff98a0be88030c5f4ba800414f911678cf9dad9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/ca6af4e20357d58d50c818d676cf2e2dd5e53b02", - "reference": "ca6af4e20357d58d50c818d676cf2e2dd5e53b02", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/ff98a0be88030c5f4ba800414f911678cf9dad9a", + "reference": "ff98a0be88030c5f4ba800414f911678cf9dad9a", "shasum": "" }, "require": { @@ -5358,7 +5358,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v8.0.4" + "source": "https://github.com/symfony/password-hasher/tree/v8.0.6" }, "funding": [ { @@ -5378,7 +5378,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T23:07:29+00:00" + "time": "2026-02-13T09:57:13+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -5953,29 +5953,29 @@ }, { "name": "symfony/property-info", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "9d987224b54758240e80a062c5e414431bbf84de" + "reference": "e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/9d987224b54758240e80a062c5e414431bbf84de", - "reference": "9d987224b54758240e80a062c5e414431bbf84de", + "url": "https://api.github.com/repos/symfony/property-info/zipball/e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba", + "reference": "e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba", "shasum": "" }, "require": { "php": ">=8.4", "symfony/string": "^7.4|^8.0", - "symfony/type-info": "^7.4.4|^8.0.4" + "symfony/type-info": "^7.4.7|^8.0.7" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "phpstan/phpdoc-parser": "^1.0|^2.0", "symfony/cache": "^7.4|^8.0", "symfony/dependency-injection": "^7.4|^8.0", @@ -6015,7 +6015,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.0.5" + "source": "https://github.com/symfony/property-info/tree/v8.0.7" }, "funding": [ { @@ -6035,7 +6035,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-03-04T15:54:04+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -6126,16 +6126,16 @@ }, { "name": "symfony/routing", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "4a2bc08d1c35307239329f434d45c2bfe8241fa9" + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/4a2bc08d1c35307239329f434d45c2bfe8241fa9", - "reference": "4a2bc08d1c35307239329f434d45c2bfe8241fa9", + "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", "shasum": "" }, "require": { @@ -6182,7 +6182,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.4" + "source": "https://github.com/symfony/routing/tree/v8.0.6" }, "funding": [ { @@ -6202,7 +6202,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/runtime", @@ -6289,16 +6289,16 @@ }, { "name": "symfony/security-bundle", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "c170650a00ba724be3455852747af600a2f042b4" + "reference": "73ba33c215a5e4516c7045c26f6fec71e4ab5727" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c170650a00ba724be3455852747af600a2f042b4", - "reference": "c170650a00ba724be3455852747af600a2f042b4", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/73ba33c215a5e4516c7045c26f6fec71e4ab5727", + "reference": "73ba33c215a5e4516c7045c26f6fec71e4ab5727", "shasum": "" }, "require": { @@ -6365,7 +6365,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v8.0.4" + "source": "https://github.com/symfony/security-bundle/tree/v8.0.6" }, "funding": [ { @@ -6385,7 +6385,7 @@ "type": "tidelift" } ], - "time": "2026-01-10T13:58:55+00:00" + "time": "2026-02-22T22:01:53+00:00" }, { "name": "symfony/security-core", @@ -6471,16 +6471,16 @@ }, { "name": "symfony/security-csrf", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/security-csrf.git", - "reference": "8be8bc615044c5911e6d15a5b0a80132068170c5" + "reference": "60efcc82a33a33df87dcdec3ce3d6915b88958fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-csrf/zipball/8be8bc615044c5911e6d15a5b0a80132068170c5", - "reference": "8be8bc615044c5911e6d15a5b0a80132068170c5", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/60efcc82a33a33df87dcdec3ce3d6915b88958fd", + "reference": "60efcc82a33a33df87dcdec3ce3d6915b88958fd", "shasum": "" }, "require": { @@ -6518,7 +6518,7 @@ "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-csrf/tree/v8.0.4" + "source": "https://github.com/symfony/security-csrf/tree/v8.0.6" }, "funding": [ { @@ -6538,20 +6538,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-02-13T09:57:13+00:00" }, { "name": "symfony/security-http", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "02f37c050db6e997052916194086d1a0a8790b8f" + "reference": "ff6cdab586fed68f1ebc2a2ed42ae0dffafada1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/02f37c050db6e997052916194086d1a0a8790b8f", - "reference": "02f37c050db6e997052916194086d1a0a8790b8f", + "url": "https://api.github.com/repos/symfony/security-http/zipball/ff6cdab586fed68f1ebc2a2ed42ae0dffafada1f", + "reference": "ff6cdab586fed68f1ebc2a2ed42ae0dffafada1f", "shasum": "" }, "require": { @@ -6605,7 +6605,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v8.0.4" + "source": "https://github.com/symfony/security-http/tree/v8.0.6" }, "funding": [ { @@ -6625,20 +6625,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/serializer", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "867a38a1927d23a503f7248aa182032c6ea42702" + "reference": "18bbaf7317e33e7e4bcd7ef281357ec4335fc900" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/867a38a1927d23a503f7248aa182032c6ea42702", - "reference": "867a38a1927d23a503f7248aa182032c6ea42702", + "url": "https://api.github.com/repos/symfony/serializer/zipball/18bbaf7317e33e7e4bcd7ef281357ec4335fc900", + "reference": "18bbaf7317e33e7e4bcd7ef281357ec4335fc900", "shasum": "" }, "require": { @@ -6646,12 +6646,13 @@ "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/property-info": "<7.3" + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", "symfony/cache": "^7.4|^8.0", @@ -6701,7 +6702,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.0.5" + "source": "https://github.com/symfony/serializer/tree/v8.0.7" }, "funding": [ { @@ -6721,7 +6722,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:43+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/service-contracts", @@ -6812,16 +6813,16 @@ }, { "name": "symfony/stimulus-bundle", - "version": "v2.32.0", + "version": "v2.33.0", "source": { "type": "git", "url": "https://github.com/symfony/stimulus-bundle.git", - "reference": "dfbf6b443bb381cb611e06f64dc23603b614b575" + "reference": "581fe67a2a85c10ce315aeba15425d5a7002526d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/dfbf6b443bb381cb611e06f64dc23603b614b575", - "reference": "dfbf6b443bb381cb611e06f64dc23603b614b575", + "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/581fe67a2a85c10ce315aeba15425d5a7002526d", + "reference": "581fe67a2a85c10ce315aeba15425d5a7002526d", "shasum": "" }, "require": { @@ -6861,7 +6862,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/v2.32.0" + "source": "https://github.com/symfony/stimulus-bundle/tree/v2.33.0" }, "funding": [ { @@ -6881,7 +6882,7 @@ "type": "tidelift" } ], - "time": "2025-12-02T07:12:06+00:00" + "time": "2026-03-15T07:57:29+00:00" }, { "name": "symfony/stopwatch", @@ -6951,16 +6952,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -7017,7 +7018,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -7037,20 +7038,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/translation", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", "shasum": "" }, "require": { @@ -7110,7 +7111,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.4" + "source": "https://github.com/symfony/translation/tree/v8.0.6" }, "funding": [ { @@ -7130,7 +7131,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/translation-contracts", @@ -7216,16 +7217,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "3e60c35cb47b1077524c066ec277eaf92cdc2393" + "reference": "e0539400f53d8305945c06eba7e8df007402f5e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/3e60c35cb47b1077524c066ec277eaf92cdc2393", - "reference": "3e60c35cb47b1077524c066ec277eaf92cdc2393", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/e0539400f53d8305945c06eba7e8df007402f5e2", + "reference": "e0539400f53d8305945c06eba7e8df007402f5e2", "shasum": "" }, "require": { @@ -7234,14 +7235,14 @@ "twig/twig": "^3.21" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/form": "<7.4.4|>8.0,<8.0.4" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/asset": "^7.4|^8.0", "symfony/asset-mapper": "^7.4|^8.0", "symfony/console": "^7.4|^8.0", @@ -7299,7 +7300,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v8.0.5" + "source": "https://github.com/symfony/twig-bridge/tree/v8.0.7" }, "funding": [ { @@ -7319,7 +7320,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:10+00:00" + "time": "2026-03-04T15:37:12+00:00" }, { "name": "symfony/twig-bundle", @@ -7407,16 +7408,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d" + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", - "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "url": "https://api.github.com/repos/symfony/type-info/zipball/3c7de103dd6cb68be24e155838a64ef4a70ae195", + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195", "shasum": "" }, "require": { @@ -7465,7 +7466,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.4" + "source": "https://github.com/symfony/type-info/tree/v8.0.7" }, "funding": [ { @@ -7485,7 +7486,7 @@ "type": "tidelift" } ], - "time": "2026-01-09T12:15:10+00:00" + "time": "2026-03-04T13:55:34+00:00" }, { "name": "symfony/uid", @@ -7567,16 +7568,16 @@ }, { "name": "symfony/ux-turbo", - "version": "v2.32.0", + "version": "v2.33.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-turbo.git", - "reference": "0deaa8abef20933d11f8bbe9899d950b4333ca1e" + "reference": "493436bc109f87663ca45c3bac8678b62a5d861f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/0deaa8abef20933d11f8bbe9899d950b4333ca1e", - "reference": "0deaa8abef20933d11f8bbe9899d950b4333ca1e", + "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/493436bc109f87663ca45c3bac8678b62a5d861f", + "reference": "493436bc109f87663ca45c3bac8678b62a5d861f", "shasum": "" }, "require": { @@ -7646,7 +7647,7 @@ "turbo-stream" ], "support": { - "source": "https://github.com/symfony/ux-turbo/tree/v2.32.0" + "source": "https://github.com/symfony/ux-turbo/tree/v2.33.0" }, "funding": [ { @@ -7666,20 +7667,20 @@ "type": "tidelift" } ], - "time": "2025-12-17T06:03:34+00:00" + "time": "2026-03-15T07:57:29+00:00" }, { "name": "symfony/validator", - "version": "v8.0.5", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "ba171e89ee2d01c24c1d8201d59ec595ef4adba1" + "reference": "04f7111e6f246d8211081fdc76e34b1298a9fc27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/ba171e89ee2d01c24c1d8201d59ec595ef4adba1", - "reference": "ba171e89ee2d01c24c1d8201d59ec595ef4adba1", + "url": "https://api.github.com/repos/symfony/validator/zipball/04f7111e6f246d8211081fdc76e34b1298a9fc27", + "reference": "04f7111e6f246d8211081fdc76e34b1298a9fc27", "shasum": "" }, "require": { @@ -7690,7 +7691,8 @@ }, "conflict": { "doctrine/lexer": "<1.1", - "symfony/doctrine-bridge": "<7.4" + "symfony/doctrine-bridge": "<7.4", + "symfony/expression-language": "<7.4" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", @@ -7740,7 +7742,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v8.0.5" + "source": "https://github.com/symfony/validator/tree/v8.0.7" }, "funding": [ { @@ -7760,20 +7762,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:10+00:00" + "time": "2026-03-06T13:17:40+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82" + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/326e0406fc315eca57ef5740fa4a280b7a068c82", - "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", "shasum": "" }, "require": { @@ -7827,7 +7829,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.4" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.6" }, "funding": [ { @@ -7847,7 +7849,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T23:07:29+00:00" + "time": "2026-02-15T10:53:29+00:00" }, { "name": "symfony/var-exporter", @@ -7931,16 +7933,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14" + "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", - "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "url": "https://api.github.com/repos/symfony/yaml/zipball/5f006c50a981e1630bbb70ad409c5d85f9a716e0", + "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0", "shasum": "" }, "require": { @@ -7982,7 +7984,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.1" + "source": "https://github.com/symfony/yaml/tree/v8.0.6" }, "funding": [ { @@ -8002,7 +8004,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:17:06+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfonycasts/sass-bundle", @@ -12714,16 +12716,16 @@ }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "2a178bf80f05dbbe469a337730eba79d61315262" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", + "reference": "2a178bf80f05dbbe469a337730eba79d61315262", "shasum": "" }, "require": { @@ -12759,7 +12761,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.6" }, "funding": [ { @@ -12779,20 +12781,20 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/dom-crawler", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "fd78228fa362b41729173183493f46b1df49485f" + "reference": "7f504fe7fb7fa5fee40a653104842cf6f851a6d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/fd78228fa362b41729173183493f46b1df49485f", - "reference": "fd78228fa362b41729173183493f46b1df49485f", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/7f504fe7fb7fa5fee40a653104842cf6f851a6d8", + "reference": "7f504fe7fb7fa5fee40a653104842cf6f851a6d8", "shasum": "" }, "require": { @@ -12829,7 +12831,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v8.0.4" + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.6" }, "funding": [ { @@ -12849,23 +12851,24 @@ "type": "tidelift" } ], - "time": "2026-01-05T09:27:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/maker-bundle", - "version": "v1.65.1", + "version": "v1.67.0", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3" + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/eba30452d212769c9a5bcf0716959fd8ba1e54e3", - "reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", "shasum": "" }, "require": { + "composer-runtime-api": "^2.1", "doctrine/inflector": "^2.0", "nikic/php-parser": "^5.0", "php": ">=8.1", @@ -12885,7 +12888,7 @@ }, "require-dev": { "composer/semver": "^3.0", - "doctrine/doctrine-bundle": "^2.5.0|^3.0.0", + "doctrine/doctrine-bundle": "^2.10|^3.0", "doctrine/orm": "^2.15|^3", "doctrine/persistence": "^3.1|^4.0", "symfony/http-client": "^6.4|^7.0|^8.0", @@ -12927,7 +12930,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.65.1" + "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" }, "funding": [ { @@ -12947,20 +12950,20 @@ "type": "tidelift" } ], - "time": "2025-12-02T07:14:37+00:00" + "time": "2026-03-18T13:39:06+00:00" }, { "name": "symfony/phpunit-bridge", - "version": "v8.0.3", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "3c59b07980df5d4ae4a2620287016209802261ea" + "reference": "f95d88d54e34b13ee220a81133261a3c8a6a287a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/3c59b07980df5d4ae4a2620287016209802261ea", - "reference": "3c59b07980df5d4ae4a2620287016209802261ea", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/f95d88d54e34b13ee220a81133261a3c8a6a287a", + "reference": "f95d88d54e34b13ee220a81133261a3c8a6a287a", "shasum": "" }, "require": { @@ -13012,7 +13015,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v8.0.3" + "source": "https://github.com/symfony/phpunit-bridge/tree/v8.0.7" }, "funding": [ { @@ -13032,20 +13035,20 @@ "type": "tidelift" } ], - "time": "2025-12-10T13:10:54+00:00" + "time": "2026-03-04T13:55:34+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "0d0df8b3601f80b455d0bf40402d104c02d8b6fa" + "reference": "141336fd018b9ac77ba4910c04c2ca05c35e7ad2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/0d0df8b3601f80b455d0bf40402d104c02d8b6fa", - "reference": "0d0df8b3601f80b455d0bf40402d104c02d8b6fa", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/141336fd018b9ac77ba4910c04c2ca05c35e7ad2", + "reference": "141336fd018b9ac77ba4910c04c2ca05c35e7ad2", "shasum": "" }, "require": { @@ -13097,7 +13100,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.4" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.7" }, "funding": [ { @@ -13117,7 +13120,7 @@ "type": "tidelift" } ], - "time": "2026-01-07T12:23:22+00:00" + "time": "2026-03-04T08:20:53+00:00" }, { "name": "thecodingmachine/phpstan-safe-rule", diff --git a/config/packages/maker.yaml b/config/packages/maker.yaml new file mode 100644 index 0000000..aebcc27 --- /dev/null +++ b/config/packages/maker.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../vendor/symfony/dependency-injection/Loader/schema/services.schema.json +when@dev: + maker: + root_namespace: 'Tvdt' + generate_final_classes: true + generate_final_entities: false diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 5902804..772e1e9 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -5,12 +5,10 @@ security: # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - # used to reload user from session & other features (e.g. switch_user) tvdt_user_provider: entity: class: Tvdt\Entity\User property: email - # used to reload user from session & other features (e.g. switch_user) firewalls: dev: # Ensure dev tools and static assets are always allowed @@ -26,15 +24,7 @@ security: default_target_path: tvdt_backoffice_index logout: path: tvdt_login_logout - # where to redirect after logout - # target: tvdt_any_route - # activate different ways to authenticate - # https://symfony.com/doc/current/security.html#the-firewall - - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true - access_control: - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/backoffice, roles: ROLE_USER } diff --git a/config/reference.php b/config/reference.php index 43dbe0f..eed7e84 100644 --- a/config/reference.php +++ b/config/reference.php @@ -208,29 +208,29 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * initial_marking?: list, * events_to_dispatch?: list|null, * places?: list, + * name?: scalar|Param|null, + * metadata?: array, * }>, - * transitions: list, * to?: list, * weight?: int|Param, // Default: 1 - * metadata?: list, + * metadata?: array, * }>, - * metadata?: list, + * metadata?: array, * }>, * }, * router?: bool|array{ // Router configuration * enabled?: bool|Param, // Default: false - * resource: scalar|Param|null, + * resource?: scalar|Param|null, * type?: scalar|Param|null, * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null * http_port?: scalar|Param|null, // Default: 80 @@ -353,10 +353,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * mapping?: array{ * paths?: list, * }, - * default_context?: list, + * default_context?: array, * named_serializers?: array, + * default_context?: array, * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true * }>, @@ -420,7 +420,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: false - * routing?: array, * }>, * serializer?: array{ @@ -433,7 +433,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * transports?: array, + * options?: array, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null * retry_strategy?: string|array{ * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null @@ -455,7 +455,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * allow_no_senders?: bool|Param, // Default: true * }, * middleware?: list, * }>, * }>, @@ -627,7 +627,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" * storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null - * policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. + * policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. * limiters?: list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). @@ -672,7 +672,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * enabled?: bool|Param, // Default: false * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" * routing?: array, * }, @@ -687,7 +687,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * dbal?: array{ * default_connection?: scalar|Param|null, * types?: array, * driver_schemes?: array, * connections?: array, * }, * filters?: array, * }>, @@ -959,7 +959,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * providers?: list, * }, * entity?: array{ - * class: scalar|Param|null, // The full entity class name of your user class. + * class?: scalar|Param|null, // The full entity class name of your user class. * property?: scalar|Param|null, // Default: null * manager_name?: scalar|Param|null, // Default: null * }, @@ -970,8 +970,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * ldap?: array{ - * service: scalar|Param|null, - * base_dn: scalar|Param|null, + * service?: scalar|Param|null, + * base_dn?: scalar|Param|null, * search_dn?: scalar|Param|null, // Default: null * search_password?: scalar|Param|null, // Default: null * extra_fields?: list, @@ -982,7 +982,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * password_attribute?: scalar|Param|null, // Default: null * }, * }>, - * firewalls: array, @@ -1040,9 +1040,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * user?: scalar|Param|null, // Default: "REMOTE_USER" * }, * login_link?: array{ - * check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". + * check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false - * signature_properties: list, + * signature_properties?: list, * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. @@ -1144,13 +1144,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * failure_handler?: scalar|Param|null, * realm?: scalar|Param|null, // Default: null * token_extractors?: list, - * token_handler: string|array{ + * token_handler?: string|array{ * id?: scalar|Param|null, * oidc_user_info?: string|array{ - * base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). * discovery?: array{ // Enable the OIDC discovery. * cache?: array{ - * id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, * }, * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" @@ -1158,25 +1158,25 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * oidc?: array{ * discovery?: array{ // Enable the OIDC discovery. - * base_uri: list, + * base_uri?: list, * cache?: array{ - * id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, * }, * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" - * audience: scalar|Param|null, // Audience set in the token, for validation purpose. - * issuers: list, - * algorithms: list, + * audience?: scalar|Param|null, // Audience set in the token, for validation purpose. + * issuers?: list, + * algorithms?: list, * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). * encryption?: bool|array{ * enabled?: bool|Param, // Default: false * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false - * algorithms: list, - * keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). + * algorithms?: list, + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). * }, * }, * cas?: array{ - * validation_url: scalar|Param|null, // CAS server validation URL + * validation_url?: scalar|Param|null, // CAS server validation URL * prefix?: scalar|Param|null, // CAS prefix // Default: "cas" * http_client?: scalar|Param|null, // HTTP Client service // Default: null * }, @@ -1550,7 +1550,10 @@ final class App */ public static function config(array $config): array { - return AppReference::config($config); + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; } } diff --git a/importmap.php b/importmap.php index 56f5dc6..0398a30 100644 --- a/importmap.php +++ b/importmap.php @@ -22,23 +22,23 @@ return [ 'path' => './assets/backoffice.js', 'entrypoint' => true, ], + '@symfony/stimulus-bundle' => [ + 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', + ], 'bootstrap' => [ - 'version' => '5.3.6', + 'version' => '5.3.8', ], '@popperjs/core' => [ 'version' => '2.11.8', ], 'bootstrap/dist/css/bootstrap.min.css' => [ - 'version' => '5.3.6', + 'version' => '5.3.8', 'type' => 'css', ], '@hotwired/stimulus' => [ 'version' => '3.2.2', ], - '@symfony/stimulus-bundle' => [ - 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', - ], '@hotwired/turbo' => [ - 'version' => '7.3.0', + 'version' => '8.0.23', ], ]; diff --git a/migrations/Version20260125191247.php b/migrations/Version20260125191247.php new file mode 100644 index 0000000..224f971 --- /dev/null +++ b/migrations/Version20260125191247.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE quiz_candidate ADD penalty_seconds SMALLINT DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE quiz_candidate DROP penalty_seconds'); + } +} diff --git a/migrations/Version20260309215703.php b/migrations/Version20260309215703.php new file mode 100644 index 0000000..be88e3c --- /dev/null +++ b/migrations/Version20260309215703.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE quiz_candidate ADD active BOOLEAN DEFAULT true NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE quiz_candidate DROP active'); + } +} diff --git a/migrations/Version20260309220448.php b/migrations/Version20260309220448.php new file mode 100644 index 0000000..dd91306 --- /dev/null +++ b/migrations/Version20260309220448.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE quiz_candidate ADD started TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL'); + + // Copy created to started for existing rows (these were created when quiz started) + $this->addSql('UPDATE quiz_candidate SET started = created WHERE started IS NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE quiz_candidate DROP started'); + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 149767b..45c4582 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -7,6 +7,8 @@ parameters: - public/ - src/ - tests/ + excludePaths: + - config/reference.php treatPhpDocTypesAsCertain: false symfony: containerXmlPath: var/cache/dev/Tvdt_KernelDevDebugContainer.xml diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index a44ec34..43e488d 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -9,14 +9,18 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Contracts\Translation\TranslatorInterface; use Tvdt\Controller\AbstractController; +use Tvdt\Entity\Answer; use Tvdt\Entity\Candidate; +use Tvdt\Entity\Question; use Tvdt\Entity\Quiz; +use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\Season; use Tvdt\Exception\ErrorClearingQuizException; use Tvdt\Repository\QuizCandidateRepository; @@ -41,10 +45,197 @@ class QuizController extends AbstractController )] public function index(Season $season, Quiz $quiz): Response { + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/overview', + name: 'tvdt_backoffice_quiz_overview', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + )] + public function overview(Season $season, Quiz $quiz): Response + { + $fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id); + + // Create indexed lookup for quiz candidates by candidate ID + $quizCandidatesByCandidateId = []; + foreach ($fetchedQuiz->candidateData as $qc) { + $quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc; + } + + // Get given answers counts efficiently via database query + $givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz); + + // Pre-compute candidate data to avoid nested loops in template + $candidateData = []; + foreach ($season->candidates as $candidate) { + $candidateIdString = $candidate->id->toString(); + $candidateData[] = [ + 'candidate' => $candidate, + 'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null, + 'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0, + ]; + } + + return $this->render('backoffice/quiz.html.twig', [ + 'season' => $season, + 'quiz' => $fetchedQuiz, + 'questionErrors' => $fetchedQuiz->getQuestionErrors(), + 'candidateData' => $candidateData, + 'activeTab' => 'overview', + 'template' => 'backoffice/quiz/tab_overview.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/result', + name: 'tvdt_backoffice_quiz_result', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + )] + public function result(Season $season, Quiz $quiz): Response + { + $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); + + return $this->render('backoffice/quiz.html.twig', [ + 'season' => $season, + 'quiz' => $fetchedQuiz, + 'result' => $this->quizRepository->getScores($quiz), + 'activeTab' => 'result', + 'template' => 'backoffice/quiz/tab_result.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates-list', + name: 'tvdt_backoffice_quiz_candidates_tab', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + )] + public function candidatesTab(Season $season, Quiz $quiz): Response + { + // Create indexed lookup for quiz candidates by candidate ID + $quizCandidatesByCandidateId = []; + foreach ($quiz->candidateData as $qc) { + $quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc; + } + + // Get given answers counts efficiently via database query + $givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz); + + // Pre-compute candidate data to avoid nested loops in template + $candidateData = []; + foreach ($season->candidates as $candidate) { + $candidateIdString = $candidate->id->toString(); + $candidateData[] = [ + 'candidate' => $candidate, + 'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null, + 'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0, + ]; + } + return $this->render('backoffice/quiz.html.twig', [ 'season' => $season, 'quiz' => $quiz, - 'result' => $this->quizRepository->getScores($quiz), + 'candidateData' => $candidateData, + 'activeTab' => 'candidates', + 'template' => 'backoffice/quiz/tab_candidates_list.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/answer-mapping', + name: 'tvdt_backoffice_quiz_candidates', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + )] + public function answerMapping(Season $season, Quiz $quiz): Response + { + $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); + \assert($fetchedQuiz->questions->count() > 0); + $firstQuestion = $fetchedQuiz->questions->first(); + \assert($firstQuestion instanceof Question); + + return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [ + 'seasonCode' => $season->seasonCode, + 'quiz' => $quiz->id, + 'question' => $firstQuestion->id, + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}', + name: 'tvdt_backoffice_quiz_candidates_question', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + methods: ['GET'], + )] + public function candidates_question(Season $season, Quiz $quiz, Question $question): Response + { + return $this->render('backoffice/quiz.html.twig', [ + 'season' => $season, + 'quiz' => $quiz, + 'question' => $question, + 'candidates' => $season->candidates, + 'activeTab' => 'answers', + 'template' => 'backoffice/quiz/tab_candidates.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}', + name: 'tvdt_backoffice_quiz_candidates_question_save', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + methods: ['POST'], + )] + public function saveCandidateAnswers(Season $season, Quiz $quiz, Question $question, Request $request, EntityManagerInterface $em): RedirectResponse + { + if (false === $season->quizzes->contains($quiz) + || false === $quiz->questions->contains($question)) { + throw new BadRequestHttpException('Invalid quiz or question'); + } + $candidateAnswers = $request->request->all('candidate_answer'); + + // Clear existing candidate-answer associations for this question + foreach ($question->answers as $answer) { + if (false === $quiz->questions->contains($answer->question)) { + throw new BadRequestHttpException('Invalid question'); + } + + $answer->candidates->clear(); + } + + // Add new associations + foreach ($candidateAnswers as $candidateId => $answerIds) { + $candidate = $em->getRepository(Candidate::class)->find($candidateId); + + if (false === $season->candidates->contains($candidate)) { + throw new BadRequestHttpException('Invalid candidate'); + } + + foreach ((array) $answerIds as $answerId) { + $answer = $em->getRepository(Answer::class)->find($answerId); + + if (false === $question->answers->contains($answer)) { + throw new BadRequestHttpException('Invalid answer'); + } + + if ($answer && $candidate) { + $answer->addCandidate($candidate); + } + } + } + + $em->flush(); + + $this->addFlash('success', $this->translator->trans('Candidate answers saved')); + + return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [ + 'seasonCode' => $season->seasonCode, + 'quiz' => $quiz->id, + 'question' => $question->id, ]); } @@ -117,4 +308,53 @@ class QuizController extends AbstractController return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); } + + #[IsGranted(SeasonVoter::EDIT, subject: 'quiz')] + #[Route( + '/backoffice/quiz/{quiz}/candidate/{candidate}/modify_penalty', + name: 'tvdt_backoffice_modify_penalty', + requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID], + )] + public function modifyPenalty(Quiz $quiz, Candidate $candidate, Request $request): RedirectResponse + { + if (!$request->isMethod('POST')) { + throw new MethodNotAllowedHttpException(['POST']); + } + + $penalty = (int) $request->request->get('penalty'); + + $this->quizCandidateRepository->setPenaltyForCandidate($quiz, $candidate, $penalty); + + return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'quiz')] + #[Route( + '/backoffice/quiz/{quiz}/candidate/{candidate}/toggle', + name: 'tvdt_backoffice_toggle_candidate', + requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID], + methods: ['GET'], + )] + public function toggleCandidate(Quiz $quiz, Candidate $candidate, EntityManagerInterface $em): RedirectResponse + { + $quizCandidate = $this->quizCandidateRepository->findOneBy([ + 'quiz' => $quiz, + 'candidate' => $candidate, + ]); + + if (!$quizCandidate instanceof QuizCandidate) { + // Create new QuizCandidate if it doesn't exist (inactive by default when first toggling) + $quizCandidate = new QuizCandidate($quiz, $candidate); + $quizCandidate->active = false; + $em->persist($quizCandidate); + } else { + $quizCandidate->active = !$quizCandidate->active; + } + + $em->flush(); + + $this->addFlash('success', $this->translator->trans('Candidate status updated')); + + return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); + } } diff --git a/src/Controller/QuizController.php b/src/Controller/QuizController.php index 07412bf..7189662 100644 --- a/src/Controller/QuizController.php +++ b/src/Controller/QuizController.php @@ -99,6 +99,14 @@ final class QuizController extends AbstractController if ('POST' === $request->getMethod()) { // TODO: Extract saving answer logic to a service + // Check if candidate is inactive for this quiz + $quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]); + if (null !== $quizCandidate && !$quizCandidate->active) { + $this->addFlash(FlashType::Danger, $this->translator->trans('You are not allowed to answer this quiz')); + + return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]); + } + $answer = $this->answerRepository->findOneBy(['id' => $request->request->get('answer')]); if (!$answer instanceof Answer) { @@ -123,7 +131,14 @@ final class QuizController extends AbstractController return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]); } - $this->quizCandidateRepository->createIfNotExist($quiz, $candidate); + $result = $this->quizCandidateRepository->createIfNotExist($quiz, $candidate); + + // Check if candidate is inactive + if (null === $result) { + $this->addFlash(FlashType::Danger, $this->translator->trans('You are not allowed to answer this quiz')); + + return $this->redirectToRoute('tvdt_quiz_enter_name', ['seasonCode' => $season->seasonCode]); + } // end of extracting getting next question logic return $this->render('quiz/question.twig', ['candidate' => $candidate, 'question' => $question, 'season' => $season]); diff --git a/src/Dto/Result.php b/src/Dto/Result.php index 0ebb5c2..f8bee43 100644 --- a/src/Dto/Result.php +++ b/src/Dto/Result.php @@ -13,6 +13,7 @@ final readonly class Result public string $name, public int $correct, public float $corrections, + public int $penaltySeconds, public \DateInterval $time, public float $score, ) {} diff --git a/src/Entity/Answer.php b/src/Entity/Answer.php index e45027c..f862b36 100644 --- a/src/Entity/Answer.php +++ b/src/Entity/Answer.php @@ -13,7 +13,7 @@ use Symfony\Component\Uid\Uuid; use Tvdt\Repository\AnswerRepository; #[ORM\Entity(repositoryClass: AnswerRepository::class)] -class Answer +class Answer implements \Stringable { #[ORM\Column(type: UuidType::NAME)] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] @@ -57,4 +57,9 @@ class Answer { $this->candidates->removeElement($candidate); } + + public function __toString(): string + { + return $this->text; + } } diff --git a/src/Entity/Question.php b/src/Entity/Question.php index 62b5ef2..8816fed 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -13,7 +13,7 @@ use Symfony\Component\Uid\Uuid; use Tvdt\Repository\QuestionRepository; #[ORM\Entity(repositoryClass: QuestionRepository::class)] -class Question +class Question implements \Stringable { #[ORM\Column(type: UuidType::NAME)] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] @@ -54,22 +54,8 @@ class Question return $this; } - public function getErrors(): ?string + public function __toString(): 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; + return $this->question ?? ''; } } diff --git a/src/Entity/Quiz.php b/src/Entity/Quiz.php index 0d369d0..b9614e6 100644 --- a/src/Entity/Quiz.php +++ b/src/Entity/Quiz.php @@ -68,4 +68,115 @@ class Quiz return $this; } + + /** + * Get errors for all questions in the quiz. + * Returns an array where keys are question IDs and values are error messages. + * + * @return array + */ + public function getQuestionErrors(): array + { + $errors = []; + + // Check if any answer in the entire quiz has candidate relations + $hasCandidateRelations = false; + foreach ($this->questions as $question) { + foreach ($question->answers as $answer) { + if ($answer->candidates->count() > 0) { + $hasCandidateRelations = true; + break 2; + } + } + } + + // Pre-compute active candidates once for all questions + $activeCandidates = []; + if ($hasCandidateRelations) { + foreach ($this->candidateData as $quizCandidate) { + if ($quizCandidate->active) { + $activeCandidates[] = $quizCandidate->candidate; + } + } + } + + foreach ($this->questions as $question) { + $error = $this->getQuestionError($question, $hasCandidateRelations, $activeCandidates); + if (null !== $error) { + $errors[$question->id->toString()] = $error; + } + } + + return $errors; + } + + /** @param list $activeCandidates */ + private function getQuestionError(Question $question, bool $hasCandidateRelations, array $activeCandidates): ?string + { + if (0 === \count($question->answers)) { + return 'This question has no answers'; + } + + $correctAnswers = $question->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'; + } + + // Only validate candidate-answer relations if at least one exists in the quiz + if ($hasCandidateRelations) { + $candidateCounts = []; + + // Count how many times each candidate appears in answers + foreach ($question->answers as $answer) { + foreach ($answer->candidates as $candidate) { + $candidateId = $candidate->id->toString(); + if (!isset($candidateCounts[$candidateId])) { + $candidateCounts[$candidateId] = ['name' => $candidate->name, 'count' => 0]; + } + + ++$candidateCounts[$candidateId]['count']; + } + } + + // Check for missing and duplicate candidates (only active ones) + $missing = []; + $duplicates = []; + + foreach ($activeCandidates as $candidate) { + $candidateId = $candidate->id->toString(); + $count = $candidateCounts[$candidateId]['count'] ?? 0; + + if (0 === $count) { + $missing[] = $candidate->name; + } elseif ($count > 1) { + $duplicates[] = $candidate->name; + } + } + + if ([] !== $missing || [] !== $duplicates) { + $errors = []; + if ([] !== $missing) { + // If all active candidates are missing, show a special message + if (\count($missing) === \count($activeCandidates)) { + $errors[] = 'No candidates assigned to this question'; + } else { + $errors[] = 'Missing candidates: '.implode(', ', $missing); + } + } + + if ([] !== $duplicates) { + $errors[] = 'Duplicate candidates: '.implode(', ', $duplicates); + } + + return implode('. ', $errors); + } + } + + return null; + } } diff --git a/src/Entity/QuizCandidate.php b/src/Entity/QuizCandidate.php index c3054d9..20f2c5d 100644 --- a/src/Entity/QuizCandidate.php +++ b/src/Entity/QuizCandidate.php @@ -24,6 +24,15 @@ class QuizCandidate #[ORM\Column] public float $corrections = 0; + #[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])] + public int $penaltySeconds = 0; + + #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])] + public bool $active = true; + + #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)] + public ?\DateTimeImmutable $started = null; + #[Gedmo\Timestampable(on: 'create')] #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)] public private(set) \DateTimeImmutable $created; diff --git a/src/Entity/Season.php b/src/Entity/Season.php index a116000..6426f61 100644 --- a/src/Entity/Season.php +++ b/src/Entity/Season.php @@ -30,6 +30,7 @@ class Season /** @var Collection */ #[ORM\OneToMany(targetEntity: Quiz::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)] + #[ORM\OrderBy(['id' => 'ASC'])] public private(set) Collection $quizzes; /** @var Collection */ @@ -39,6 +40,7 @@ class Season /** @var Collection */ #[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'seasons')] + #[ORM\OrderBy(['email' => 'ASC'])] public private(set) Collection $owners; #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php index c274979..b8ffe67 100644 --- a/src/Form/SettingsForm.php +++ b/src/Form/SettingsForm.php @@ -17,12 +17,16 @@ class SettingsForm extends AbstractType { $builder ->add('showNumbers', options: [ + 'label' => 'Show Numbers', 'label_attr' => ['class' => 'checkbox-switch'], 'attr' => ['role' => 'switch', 'switch' => null]]) ->add('confirmAnswers', options: [ + 'label' => 'Confirm Answers', 'label_attr' => ['class' => 'checkbox-switch'], 'attr' => ['role' => 'switch', 'switch' => null]]) - ->add('save', SubmitType::class) + ->add('save', SubmitType::class, [ + 'label' => 'Save', + ]) ; } diff --git a/src/Repository/QuizCandidateRepository.php b/src/Repository/QuizCandidateRepository.php index d51a289..791238c 100644 --- a/src/Repository/QuizCandidateRepository.php +++ b/src/Repository/QuizCandidateRepository.php @@ -6,6 +6,7 @@ namespace Tvdt\Repository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +use Safe\DateTimeImmutable; use Tvdt\Entity\Candidate; use Tvdt\Entity\Quiz; use Tvdt\Entity\QuizCandidate; @@ -20,14 +21,28 @@ class QuizCandidateRepository extends ServiceEntityRepository parent::__construct($registry, QuizCandidate::class); } - /** @return bool true if a new entry was created */ - public function createIfNotExist(Quiz $quiz, Candidate $candidate): bool + /** @return bool|null true if a new entry was created, false if it already exists, null if candidate is inactive */ + public function createIfNotExist(Quiz $quiz, Candidate $candidate): ?bool { - if (0 !== $this->count(['candidate' => $candidate, 'quiz' => $quiz])) { + $quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]); + + if (null !== $quizCandidate) { + // Check if candidate is inactive + if (!$quizCandidate->active) { + return null; + } + + // If QuizCandidate exists but hasn't started yet, set the started timestamp + if (null === $quizCandidate->started) { + $quizCandidate->started = new DateTimeImmutable(); + $this->getEntityManager()->flush(); + } + return false; } $quizCandidate = new QuizCandidate($quiz, $candidate); + $quizCandidate->started = new DateTimeImmutable(); $this->getEntityManager()->persist($quizCandidate); $this->getEntityManager()->flush(); @@ -44,4 +59,15 @@ class QuizCandidateRepository extends ServiceEntityRepository $quizCandidate->corrections = $corrections; $this->getEntityManager()->flush(); } + + public function setPenaltyForCandidate(Quiz $quiz, Candidate $candidate, int $penalty): void + { + $quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]); + if (!$quizCandidate instanceof QuizCandidate) { + throw new \InvalidArgumentException('Quiz candidate not found'); + } + + $quizCandidate->penaltySeconds = $penalty; + $this->getEntityManager()->flush(); + } } diff --git a/src/Repository/QuizRepository.php b/src/Repository/QuizRepository.php index 92d468d..9a89c00 100644 --- a/src/Repository/QuizRepository.php +++ b/src/Repository/QuizRepository.php @@ -9,6 +9,7 @@ use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; use Safe\DateTimeImmutable; use Safe\Exceptions\DatetimeException; +use Symfony\Component\Uid\Uuid; use Tvdt\Dto\Result; use Tvdt\Entity\Quiz; use Tvdt\Exception\ErrorClearingQuizException; @@ -81,26 +82,86 @@ class QuizRepository extends ServiceEntityRepository c.name, sum(case when a.isRightAnswer = true then 1 else 0 end) as correct, qd.corrections, + qd.penaltySeconds, max(ga.created) as end_time, - qd.created as start_time, + qd.started as start_time, (sum(case when a.isRightAnswer = true then 1 else 0 end) + qd.corrections) as score from Tvdt\Entity\Candidate c join c.givenAnswers ga join ga.answer a join c.quizData qd - where qd.quiz = :quiz and ga.quiz = :quiz + where qd.quiz = :quiz and ga.quiz = :quiz and qd.started is not null group by ga.quiz, c.id, qd.id - order by score desc, max(ga.created) - qd.created asc + order by score desc, max(ga.created) - qd.started asc DQL )->setParameter('quiz', $quiz)->getResult(); - return array_map(static fn (array $row): Result => new Result( - id: $row['id'], - name: $row['name'], - correct: (int) $row['correct'], - corrections: $row['corrections'], - time: $row['start_time']->diff(new DateTimeImmutable($row['end_time'])), - score: $row['score'], - ), $result); + return array_map(static function (array $row): Result { + \assert($row['start_time'] instanceof \DateTimeImmutable); + + return new Result( + id: $row['id'], + name: $row['name'], + correct: (int) $row['correct'], + corrections: $row['corrections'], + penaltySeconds: $row['penaltySeconds'], + time: $row['start_time']->diff(new DateTimeImmutable($row['end_time'])), + score: $row['score'], + ); + }, $result); + } + + public function fetchWithQuestions(Uuid $id): Quiz + { + return $this->getEntityManager()->createQuery(<<setParameter('id', $id)->getSingleResult(); + } + + /** + * Fetch quiz with all relations needed for error checking. + * This includes: questions, answers, answer candidates, and season candidates. + */ + public function fetchWithQuestionsAndCandidates(Uuid $id): Quiz + { + return $this->getEntityManager()->createQuery(<<setParameter('id', $id)->getSingleResult(); + } + + /** + * Get given answers count per candidate for a quiz. + * + * @return array Array with candidate ID as key and count as value + */ + public function getGivenAnswersCountPerCandidate(Quiz $quiz): array + { + $results = $this->getEntityManager()->createQuery(<<setParameter('quiz', $quiz) + ->setParameter('season', $quiz->season) + ->getResult(); + + $counts = []; + foreach ($results as $row) { + $counts[$row['candidateId']->toString()] = (int) $row['answerCount']; + } + + return $counts; } } diff --git a/templates/backoffice/base.html.twig b/templates/backoffice/base.html.twig index 111d1d6..5aeeaa2 100644 --- a/templates/backoffice/base.html.twig +++ b/templates/backoffice/base.html.twig @@ -2,3 +2,16 @@ {% block importmap %}{{ importmap('backoffice') }}{% endblock %} {% block title %}Tijd voor de test | {% endblock %} {% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %} + +{% block main %} +
+
+ {% block breadcrumbs %}{% endblock %} +
+ {{ include('flashes.html.twig') }} +
+ {% block body %} + {% endblock body %} +
+
+{% endblock %} diff --git a/templates/backoffice/index.html.twig b/templates/backoffice/index.html.twig index 43afa5b..4fd6c99 100644 --- a/templates/backoffice/index.html.twig +++ b/templates/backoffice/index.html.twig @@ -2,9 +2,17 @@ {% block title %}{{ parent() }}Backoffice{% endblock %} +{% block breadcrumbs %} + +{% endblock %} + {% block body %} -
-

+
+

{{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }}

@@ -12,7 +20,7 @@
{% if seasons %} - +
{% if is_granted('ROLE_ADMIN') %} diff --git a/templates/backoffice/prepare_elimination/index.html.twig b/templates/backoffice/prepare_elimination/index.html.twig index b873316..1c50933 100644 --- a/templates/backoffice/prepare_elimination/index.html.twig +++ b/templates/backoffice/prepare_elimination/index.html.twig @@ -1,20 +1,17 @@ {% extends 'backoffice/base.html.twig' %} -{% block body %} -
- -
+{% block breadcrumbs %} + +{% endblock %} + +{% block body %}
@@ -32,7 +29,7 @@
{% endfor %} -
+
@@ -42,7 +39,7 @@
-

Hier kan dus weer wat uitleg komen

+

{{ 'Help text for preparing elimination'|trans }}

{% endblock %} diff --git a/templates/backoffice/quiz.html.twig b/templates/backoffice/quiz.html.twig index ea19683..bbb9fa4 100644 --- a/templates/backoffice/quiz.html.twig +++ b/templates/backoffice/quiz.html.twig @@ -1,166 +1,36 @@ {% extends 'backoffice/base.html.twig' %} -{% block title %}{{ parent() }}{{ quiz.season.name }}{% endblock %} +{% block title %}{{ parent() }}{{ season.name }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} {% block body %} -

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

-
- {{ 'Make active'|trans }} - {% if quiz is same as (season.activeQuiz) %} - {{ 'Deactivate Quiz'|trans }} - {% endif %} - - -
+ {% set tabs = [ + {id: 'overview', label: 'Overview'|trans, route: 'tvdt_backoffice_quiz_overview'}, + {id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_quiz_candidates_tab'}, + {id: 'result', label: 'Results & Elimination'|trans, route: 'tvdt_backoffice_quiz_result'}, + {id: 'answers', label: 'Answer Mapping'|trans, route: 'tvdt_backoffice_quiz_candidates'}, + ] %} -
-

{{ 'Questions'|trans }}

-
- {%~ for question in quiz.questions ~%} -
-

- -

-
-
-
    - {%~ for answer in question.answers %} - {{ answer.text -}} - {%~ else %} - {{ 'There are no answers for this question'|trans -}} - {%~ endfor %} -
-
-
-
- {% else %} - {{ 'EMPTY'|trans }} - {% endfor %} -
-
-
-

{{ 'Score'|trans }}

- -

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

-
- - - - - - - - - - - {%~ for candidate in result ~%} - - - - - - - - {% else %} - - - - {% endfor %} - -
{{ 'Candidate'|trans }}{{ 'Correct Answers'|trans }}{{ 'Corrections'|trans }}{{ 'Score'|trans }}{{ 'Time'|trans }}
{{ candidate.name }}{{ candidate.correct|default('0') }} -
-
-
- -
-
- -
-
-
-
{{ candidate.score|default('x') }}{{ candidate.time.format('%i:%S') }}
{{ 'No results'|trans }}
-

- - {# Modal Clear #} - - - {# Modal Delete #} -