Compare commits

...

12 Commits

Author SHA1 Message Date
Marijn 82ce5edb28 fix: set explicit form action URLs for Turbo Frame modal forms
When a form has no explicit action, Symfony renders action="" which the
browser resolves to the page URL, not the URL the Turbo Frame was
fetched from. This caused edits submitted via the modal to POST to the
wrong route and silently discard changes.
2026-07-07 09:30:58 +02:00
Marijn 8d40559370 fix: correct Dutch translations flagged in review
- 'Error saving order': 'Fout bij het opslaan van de volgorde' (consistent with sibling strings)
- 'Owner(s)': 'Eigenaar/Eigenaren' (unambiguous, correct Dutch plural)
2026-07-07 09:01:44 +02:00
Marijn e57ed79b35 fix: address CodeRabbit review findings on PR #183
- Add SeasonVoter::EDIT guard to QuizQuestionController::view()
- Add full-count check in reorder() to reject partial ordering payloads
- Retry once in _persistOrder(); lock drag and show dismissible alert on failure
- Remove tabindex="-1" from correct-answer toggle button (accessibility)
- Replace 'EMPTY'|trans placeholder with proper copy
- Add data-modal-title to question bank Edit button to prevent stale title
2026-07-07 08:58:55 +02:00
Marijn e2558c70f5 fix: use correct Turbo v8 session export to disable Drive 2026-07-07 08:33:34 +02:00
Marijn 12d40b2602 fix: exclude auto-generated reference.php from pre-commit CS-fixer 2026-07-07 08:01:57 +02:00
Marijn cb577d60d3 feat: replace fetch-based modal forms with Turbo Frames (#181)
Enable @hotwired/turbo with Drive explicitly disabled, then migrate the
bo--modal-form Stimulus controller (custom fetch + X-Modal-Request pattern)
to a thin bo--modal controller that lets Turbo handle HTTP and DOM swap.
Adds frame templates for quiz questions and bank questions; controllers now
detect Turbo-Frame header instead of X-Modal-Request.
2026-07-06 23:13:55 +02:00
Marijn 3b6ff680e5 fix: modal save button + missing translations
- Replace form="id" cross-element approach with requestSubmit() for
  reliable save button wiring in modal footer
- Re-call _bindDirty after validation-error re-render so dirty guard
  is preserved across save attempts
- Translate missing Dutch strings: Order saved, Error saving order,
  Question details, View
2026-07-06 22:41:34 +02:00
Marijn 4a87ac574d feat: add bank question via modal + dirty modal guard
- Add question in question bank now opens a modal instead of navigating
  to a full-page form, consistent with the edit modal pattern
- Modal closes are blocked by static backdrop once the user has made any
  change (input, checkbox, drag-reorder, sort, randomize, remove answer)
- Dirty state resets when the modal is fully hidden
2026-07-06 22:32:44 +02:00
Marijn 394b8c7d33 feat: answer field UX improvements
- Auto-add one empty answer field when opening a blank question form
- Auto-append new empty field when typing in the last answer field
- Strip empty answer rows before submit (novalidate + JS cleanup)
- Tab key skips correct/delete buttons, jumping straight to next answer
2026-07-06 22:28:57 +02:00
Marijn 64a09453e6 feat: quiz page question rework (#181)
- Replace Bootstrap accordion with flat card list for questions
- Add HTML5 drag-and-drop reordering with placeholder-between-cards UX
  and amber/green/red save status indicator next to the heading
- Add edit button per question opening a Bootstrap modal (bo--modal-form
  Stimulus controller with X-Modal-Request header pattern)
- Show read-only view button instead of edit for locked/finalized quizzes
- Add BankQuestion edit modal in question bank tab using same infrastructure
- Move modal action buttons into modal-footer via <template data-modal-footer>
- Fix IS_AUTHENTICATED_FULLY 403: replace ROLE_USER with IS_AUTHENTICATED
  on all backoffice controllers and in security.yaml access_control
2026-07-06 22:19:51 +02:00
Marijn 88cff7f480 feat: Sentry User Feedback in backoffice (#178) (#180)
* feat: add Sentry User Feedback widget to backoffice (#178)

Install @sentry/browser via AssetMapper and initialize the feedback
integration on all backoffice pages. The CDN loader script is moved to
an overridable block so backoffice pages use the npm SDK without
double-initialising Sentry.

Sentry is initialised only when a DSN is configured (SENTRY_DSN env
var). When a user is logged in their email is pre-filled in the feedback
form via Sentry.setUser(); both name and email remain optional so
anonymous submissions are supported by simply leaving those fields empty.

* feat: add Spotlight sidecar for local Sentry development testing

Add the Spotlight container to compose.override.yaml so the feedback
widget and error events can be inspected locally without sending anything
to Sentry. The JS SDK switches to spotlight mode automatically when no
SENTRY_DSN is configured.

* feat: route PHP Sentry events to Spotlight in dev

Add a when@dev block to sentry.yaml that initialises the PHP Sentry SDK
with a placeholder DSN and forwards all events to the Spotlight sidecar
over the internal Docker network (http://spotlight:8969/stream).

The JS SDK already uses localhost:8969 via spotlight:true when no real
SENTRY_DSN is set, which is correct since the browser cannot reach the
internal Docker hostname.

* fix: use spotlight_url to route PHP dev errors to Spotlight sidecar

* fix: manually mount Sentry feedback widget to work around v10 setupOnce guard

Sentry v10's setupOnce() is skipped when the integration name is already
in the module-level guard array, so autoInject never mounts the widget.
Switching to autoInject: false and calling createWidget() directly is
reliable and confirmed working in the browser.

Also use the tunnel option to forward JS events to the local Spotlight
sidecar when no real DSN is configured, and enable SentryBundle in dev
so PHP errors are also routed to Spotlight.
2026-07-05 23:27:49 +02:00
Marijn 135e4f0ae5 feat: season question bank & quiz finalisation (#101) (#176)
* feat: add question bank management, quiz finalization, and related backend/frontend functionality

* chore: add symfony/object-mapper dependency and fix Finalized translation

* feat: address PR review comments — unassign/sync bank questions, blank quiz creation, deactivate redirect, remove duplicate tab titles

- Use Symfony ObjectMapper for BankQuestion/BankAnswer → Question/Answer copy (#[Map(if: false)] on id, season, etc.)
- Track created Question on BankQuestionUsage (nullable FK, onDelete: SET NULL) for unassign/sync support
- Add unassign route: removes the Question copy + usage record
- Add sync route: pushes bank question edits to a finalized-not-started quiz copy
- Auto-sync non-finalized quiz copies on bank question edit; flash warning for finalized-not-started
- Add blank quiz creation (no XLSX required) with new route + template
- Deactivate quiz button now stays on the quiz overview page (redirect_quiz hidden field)
- Remove duplicate h4 titles below the tab bar on all season tabs
- Add migration for bank_question_usage.question_id
- Add Dutch translations for all new strings

* fix: address CodeRabbit review findings

- Use FlashType enum in clearQuiz/finalizeQuiz/unfinalizeQuiz (was raw 'success'/'error' strings)
- Catch UniqueConstraintViolationException in addLabel to handle concurrent duplicate inserts
- Wrap assignToQuiz in a transaction with PESSIMISTIC_WRITE lock to serialise concurrent assignments of the same BankQuestion

* ci: build SCSS before running PHPUnit tests

* test: remove QueryCountTest (covered by Sentry in production)

* fix: crash on empty-quiz overview and answer-mapping, use FlashType enum consistently

- fetchWithQuestionsAndCandidates / fetchWithQuestions used INNER JOINs on
  questions/answers, so quizzes with no questions threw NoResultException (500)
  when opening the overview tab. Switched to LEFT JOINs.
- answerMapping bare \assert() replaced with a proper flash + redirect when
  the quiz has no questions, instead of crashing with AssertionError.
- Three raw 'success' flash strings in QuizController replaced with FlashType::Success.
- Added Dutch translation for "This quiz has no questions yet".
- Two new tests: empty-quiz overview loads (200), answer-mapping redirects with flash.

* feat: add contextual help panels to all backoffice pages

Add a 50/50 or 66/33 split layout to every backoffice page with Dutch
instructions explaining how to use Tijd voor de Test. Content covers the
overall workflow, quiz finalize/activate flow, and both candidate
participation methods (own device vs. shared laptop).

Help text lives in dedicated partials under templates/backoffice/help/ and
is loaded via Twig include(), keeping page templates clean. All strings use
a separate 'instructions' translation domain (instructions+intl-icu.nl.xliff)
isolated from the main messages domain.

Also updates 'Finalize'-related Dutch translations to use 'Afronden' and
adds tooltips to the finalize/unfinalize buttons.

* refactor: move help content out of translations into locale-specific partials

Replace the instructions translation domain with plain HTML files under
templates/backoffice/help/nl/. Each help/*.html.twig is now a locale
dispatch shim that tries the current locale first and falls back to nl,
so adding English (or any other language) is simply a matter of creating
a help/en/ directory with the translated files — no code changes needed.

Removes instructions+intl-icu.nl.xliff.

* fix: address PR review feedback on help texts and translations

- Replace 'speelronde' with 'spel' in index and season_add help
- Season settings help: remove incorrect claim name is editable, describe
  actual settings (Show Numbers, Confirm Answers)
- quiz_add help: rename 'XLSX-bestand' to 'Excel-bestand', add explanation
  of WAAR/ONWAAR (Dutch Excel) vs TRUE/FALSE (English Excel) for marking
  the correct answer
- quiz_answer_mapping help: remove em-dashes
- quiz_candidates help: clarify that multiple devices and mixed setups
  (multiple laptops, phones, or a mix) are supported
- quiz_question_bank_form help: change 'thema' to 'type vraag' for labels
- Rename 'Add from XLSX' to 'Import' in translation and template

* fix: remove all em-dashes from nl help files, fix remaining XLSX references

Replace all em-dashes with semicolons or colons throughout the nl help
partials. Also replace remaining 'XLSX-bestand' with 'Excel-bestand' in
season_tests and index, and also also fix the em-dash that was still on
the same line as the 'speelronde -> spel' fix in index.

* style: replace semicolons with commas in nl help content, document writing rules

Semicolons in help text read as AI-generated; commas are more natural.
Added writing style rules to CLAUDE.md to prevent recurrence.

* fix: correct lock guards, flush batching, and clearQuiz atomicity in question bank

- syncUsagesAfterEdit: use isLocked() instead of !isFinalized() to avoid
  syncing quizzes with started candidates (would have destroyed GivenAnswer records)
- syncToQuiz action: use isLocked() instead of hasStartedCandidates() to block
  syncing into finalized quizzes that have no started candidates
- QuestionBankService::syncToQuiz: remove internal flush(); callers now
  flush once (syncUsagesAfterEdit after the loop, syncToQuiz action after the call)
- QuizRepository::clearQuiz: delete BankQuestionUsage rows and reset
  finalized_at inside the transaction (previously orphaned usages blocked
  bank question reassignment; finalizedAt reset was a separate non-atomic flush)
- QuizController::finalizeQuiz: add flash when quiz is already finalized
- QuestionBankController::delete: block deletion when any usage references a locked quiz
- BankQuestion::__toString: remove dead null-coalescing on non-nullable string
- SeasonController::addBlankQuiz: align form field with UploadQuizFormType
  (add translation_domain: false, use translator for label)

* fix: pass season variable to season_add_candidates template

* Textual changes to help content.

* Manual text changes

* feat: address PR review — property hooks, slug labels, label colours, drag sort, Loggable, and more

- Convert Quiz.isFinalized/isLocked/hasStartedCandidates and BankQuestion.isUsed/canBeAssigned to PHP 8.4 property get hooks; update all PHP and test call sites
- Add LabelColour enum (Bootstrap colour names) with colour column on QuestionLabel; badges in templates reflect label colour
- Add slug field to QuestionLabel with unique-per-season constraint; label filter and delete URLs now use slug instead of UUID; slugger generates and uniqueness-checks slug on save
- Allow saving bank questions without answers; isCompleteForQuiz hook enforces completeness before assigning to a quiz; BankQuestionIncompleteException for user-facing feedback
- Split BankQuestionRepository findBySeason into separate queries to avoid Cartesian-product row explosion across multiple collections
- Enable Gedmo Loggable on BankQuestion (question and reusable fields versioned); custom LogEntry entity uses json type for PostgreSQL compatibility; migrations for ext_log_entries and new columns
- Add SeasonController blank-quiz form validation (NotBlank, Length) and catch UniqueConstraintViolation as form error
- Replace &times; with bi-trash icon on answer delete buttons and label delete buttons
- Add drag-and-drop reordering with grab handles, sort-alphabetically, and randomize buttons to answer collection in question bank form
- Replace raw 'success'/'error' flash strings with FlashType enum in RegistrationController and PrepareEliminationController
- Add testDeactivateWithRedirectQuizStaysOnQuizOverview test covering enableQuiz redirect_quiz branch

* fix: migration to align ext_log_entries id/data types and drop slug default

* refactor: squash three PR migrations into one

* feat: question bank UX — ordering, correct-answer toggle, label colour picker

Answer ordering:
- Remove applyAnswerOrdering from edit action (it was overwriting form-submitted
  ordering with the original Doctrine-loaded order, discarding user reordering)
- Call _syncOrdering() on Stimulus connect() and addItem() so hidden ordering
  inputs are always populated before submission
- Fix drag-and-drop to insert before/after based on cursor position relative to
  the target item's midpoint, enabling drop to the bottom position

Correct-answer toggle:
- Replace checkbox + "Correct" label with a filled green ✓ / red ✗ button
- Checkbox is kept hidden (d-none) so form submission still works; button
  syncs the checkbox state on click

Label colour picker:
- Always show label colours in the filter bar (opacity-50 when inactive,
  full opacity when active) so colours are visible without selecting a filter
- Add a reusable modal component (templates/components/modal.html.twig) using
  Twig embed blocks (modal_trigger, modal_body, modal_footer)
- Replace inline add-label form with the modal, adding a colour picker that
  renders swatches as coloured badges (faded when unselected, full opacity
  with white ring when selected) matching how labels appear in the filter bar
- Controller now accepts and persists the selected colour on label creation

* refactor: rename and standardize label colours, update default values, and add new translations

* fix: question bank layout — help text beside content, icon-only action buttons

Move labels filter and table inside the main column so help text sits
beside the full content rather than just the add button. Convert Edit,
Delete, and Assign buttons to icon-only btn-group to save row space.

* refactor: abstract base for Answer/BankAnswer; add quiz question edit

- Extract AbstractBaseAnswer (MappedSuperclass) sharing ordering, text,
  isRightAnswer, constructor, and __toString between Answer and BankAnswer
- Extract AbstractBaseAnswerFormType sharing buildForm between
  BankAnswerFormType and new AnswerFormType
- Add QuestionFormType for editing quiz-level Question entities
- Add QuizQuestionController with edit route guarded by MODIFY_QUIZ_CONTENT
  voter (hidden on locked/finalized quizzes)
- Add question edit template reusing the shared answer_row Twig macro
- Show Edit button per question in quiz overview accordion

* fix: use colour label instead of translated name in question bank picker

* fix: translate LabelColour label in question bank colour picker

TranslatableMessage cannot be coerced to string by Twig without |trans.
2026-07-05 21:36:33 +02:00
119 changed files with 4774 additions and 375 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ setopt ERR_EXIT PIPE_FAIL NOUNSET
# Collect staged PHP and Twig files # Collect staged PHP and Twig files
STAGED_PHP=() STAGED_PHP=()
while IFS= read -r file; do while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_PHP+=("$file") [[ -n "$file" && "$file" != "config/reference.php" ]] && STAGED_PHP+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true) done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true)
STAGED_TWIG=() STAGED_TWIG=()
@@ -32,7 +32,7 @@ if [[ ${#STAGED_PHP[@]} -gt 0 ]]; then
git add "${STAGED_PHP[@]}" git add "${STAGED_PHP[@]}"
echo " → PHP-CS-Fixer" echo " → PHP-CS-Fixer"
"${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php "${STAGED_PHP[@]}" "${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --path-mode=intersection "${STAGED_PHP[@]}"
git add "${STAGED_PHP[@]}" git add "${STAGED_PHP[@]}"
echo " → PHPStan" echo " → PHPStan"
+2
View File
@@ -141,6 +141,8 @@ jobs:
*.cache-from=type=gha,scope=${{github.ref}}-devbuild *.cache-from=type=gha,scope=${{github.ref}}-devbuild
- name: Start services - name: Start services
run: docker compose up php database --wait --no-build run: docker compose up php database --wait --no-build
- name: Build SCSS
run: docker compose exec -T php bin/console sass:build
- name: Create test database - name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations - name: Run migrations
+1
View File
@@ -170,6 +170,7 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/ergebnis/agent-detector" /> <excludeFolder url="file://$MODULE_DIR$/vendor/ergebnis/agent-detector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" /> <excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
Generated
+163 -162
View File
@@ -41,169 +41,170 @@
</component> </component>
<component name="PhpIncludePathManager"> <component name="PhpIncludePathManager">
<include_path> <include_path>
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/gedmo/doctrine-extensions" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/twig/intl-extra" />
<path value="$PROJECT_DIR$/vendor/dama/doctrine-test-bundle" />
<path value="$PROJECT_DIR$/vendor/stof/doctrine-extensions-bundle" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/jean85/pretty-package-versions" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/rector/rector" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry-symfony" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/form" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-deepclone" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/extension-installer" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/brevo-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/ergebnis/agent-detector" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/sebastian/file-filter" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/git-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/sass-bundle" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/composer" /> <path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/dama/doctrine-test-bundle" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/twig/intl-extra" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/stof/doctrine-extensions-bundle" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/rector/rector" />
<path value="$PROJECT_DIR$/vendor/jean85/pretty-package-versions" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry-symfony" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/gedmo/doctrine-extensions" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phpstan/extension-installer" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<path value="$PROJECT_DIR$/vendor/symfony/form" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-deepclone" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/brevo-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/ergebnis/agent-detector" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/sass-bundle" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/file-filter" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/sebastian/git-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
</include_path> </include_path>
</component> </component>
<component name="PhpInterpreters"> <component name="PhpInterpreters">
+9
View File
@@ -224,6 +224,15 @@ Auto-executed scripts on install/update:
- `assets:install` - Copy public assets - `assets:install` - Copy public assets
- `importmap:install` - JS import map setup - `importmap:install` - JS import map setup
## Writing Style (Help Content & UI Text)
When writing Dutch help content in `templates/backoffice/help/nl/`:
- **No em-dashes** (—): use a comma or restructure the sentence instead.
- **No semicolons** (;): use a comma. Semicolons are technically correct but read as AI-generated text.
- **Natural Dutch**: write the way a person would explain it to a colleague, not in formal documentation style.
- **Colons after bold labels** (e.g. `<strong>Label:</strong> description`) are fine and intentional.
## Notes for Future Work ## Notes for Future Work
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php` - The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
-1
View File
@@ -32,7 +32,6 @@ RUN set -eux; \
opcache \ opcache \
zip \ zip \
gd \ gd \
excimer \
; ;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
+37
View File
@@ -1,4 +1,41 @@
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss'; import './styles/backoffice.scss';
import {session as turboSession} from '@hotwired/turbo';
turboSession.drive = false;
import './stimulus.js'; import './stimulus.js';
import './bootstrap.js'; import './bootstrap.js';
import * as Sentry from '@sentry/browser';
const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? '';
const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? '';
// When no real DSN is configured, route to the local Spotlight sidecar so
// nothing reaches Sentry. A syntactically valid DSN is still required for the
// SDK to initialise; the tunnel option redirects all transport to Spotlight.
const useSpotlight = !dsn;
const effectiveDsn = dsn || 'https://0@o0.ingest.sentry.io/0';
const feedbackIntegration = Sentry.feedbackIntegration({
colorScheme: 'system',
showName: false,
showEmail: true,
isEmailRequired: false,
autoInject: false,
triggerLabel: 'Report feedback',
formTitle: 'Report Feedback',
submitButtonLabel: 'Send Feedback',
});
Sentry.init({
dsn: effectiveDsn,
tunnel: useSpotlight ? 'http://localhost:8969/stream' : undefined,
integrations: [feedbackIntegration],
});
// autoInject is unreliable in Sentry v10 due to the setupOnce guard; mount manually.
feedbackIntegration.createWidget();
if (userEmail) {
Sentry.setUser({ email: userEmail });
}
@@ -0,0 +1,135 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['collection'];
static values = {prototype: String};
connect() {
this.index = this.collectionTarget.children.length;
this._setupDrag();
this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
this.collectionTarget.addEventListener('input', (e) => {
if (e.target.type !== 'text') return;
const item = e.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && e.target.value.trim() !== '') {
this.addItem();
}
});
const form = this.element.closest('form');
if (form) {
form.addEventListener('submit', () => {
[...this.collectionTarget.children].forEach(item => {
const input = item.querySelector('input[type="text"]');
if (input && input.value.trim() === '') item.remove();
});
});
}
}
addItem() {
const item = document.createElement('div');
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild;
this.collectionTarget.appendChild(el);
this._makeDraggable(el);
this.index++;
this._syncOrdering();
}
removeItem(event) {
event.target.closest('[data-collection-item]').remove();
this._notifyChange();
}
sortAlphabetically() {
const items = [...this.collectionTarget.children];
items.sort((a, b) => {
const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
return textA.localeCompare(textB);
});
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
randomize() {
const items = [...this.collectionTarget.children];
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
}
// — drag-and-drop —
_setupDrag() {
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
}
_makeDraggable(el) {
const handle = el.querySelector('[data-drag-handle]');
if (!handle) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (e) => {
this._dragging = el;
el.classList.add('opacity-50');
e.dataTransfer.effectAllowed = 'move';
});
handle.addEventListener('dragend', () => {
this._dragging = null;
el.classList.remove('opacity-50');
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary'));
});
el.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this._dragging || this._dragging === el) return;
e.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
});
el.addEventListener('dragleave', () => {
el.classList.remove('border-top', 'border-bottom', 'border-primary');
});
el.addEventListener('drop', (e) => {
e.preventDefault();
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
});
}
_syncOrdering() {
[...this.collectionTarget.children].forEach((el, i) => {
const input = el.querySelector('input[name*="[ordering]"]');
if (input) input.value = i;
});
}
}
+48
View File
@@ -0,0 +1,48 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
export default class extends Controller {
static targets = ['modal', 'frame'];
open(event) {
event.preventDefault();
const {src, modalTitle} = event.currentTarget.dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this._resetDirty();
this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameLoad() {
this._bindDirty();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
window.location.reload();
}
}
_bindDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
const markDirty = () => {
modal._config.backdrop = 'static';
modal._config.keyboard = false;
};
this.frameTarget.addEventListener('input', markDirty, {once: true});
this.frameTarget.addEventListener('change', markDirty, {once: true});
this.modalTarget.addEventListener('hidden.bs.modal', () => this._resetDirty(), {once: true});
}
_resetDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
@@ -0,0 +1,134 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
canModify: Boolean,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
connect() {
this._locked = false;
if (this.canModifyValue) {
this._setupDrag();
}
}
_setupDrag() {
this.itemTargets.forEach(el => {
const handle = el.querySelector('[data-drag-handle]');
if (!handle) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (e) => {
this._dragging = el;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => el.classList.add('opacity-50'), 0);
});
handle.addEventListener('dragend', () => {
el.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
});
});
this.listTarget.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this._dragging) return;
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = e.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
});
this.listTarget.addEventListener('dragleave', (e) => {
if (!e.relatedTarget || !this.listTarget.contains(e.relatedTarget)) {
this._removePlaceholder();
}
});
this.listTarget.addEventListener('drop', async (e) => {
e.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
});
}
_removePlaceholder() {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state) {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove('d-none', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder() {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId);
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
this._locked = true;
this._setStatus('error');
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${this.errorLabelValue || 'Error saving order'} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
}
}
+2
View File
@@ -1,3 +1,5 @@
.col-result-xs { width: 10%; } .col-result-xs { width: 10%; }
.col-result-sm { width: 15%; } .col-result-sm { width: 15%; }
.col-result-md { width: 20%; } .col-result-md { width: 20%; }
.modal-content > turbo-frame { display: contents; }
+5
View File
@@ -70,5 +70,10 @@ services:
MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ### ###< symfony/mailer ###
spotlight:
image: ghcr.io/getsentry/spotlight:latest
ports:
- "8969:8969"
volumes: volumes:
sass: sass:
+1
View File
@@ -28,6 +28,7 @@
"symfony/form": "8.1.*", "symfony/form": "8.1.*",
"symfony/framework-bundle": "8.1.*", "symfony/framework-bundle": "8.1.*",
"symfony/mailer": "8.1.*", "symfony/mailer": "8.1.*",
"symfony/object-mapper": "8.1.*",
"symfony/property-access": "8.1.*", "symfony/property-access": "8.1.*",
"symfony/property-info": "8.1.*", "symfony/property-info": "8.1.*",
"symfony/runtime": "8.1.*", "symfony/runtime": "8.1.*",
Generated
+74 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f8e4107825dba89eaa38d067d47856ce", "content-hash": "7171824ca13f4df0801dfa5d7f58d6a0",
"packages": [ "packages": [
{ {
"name": "composer/pcre", "name": "composer/pcre",
@@ -5337,6 +5337,79 @@
], ],
"time": "2026-05-29T05:06:50+00:00" "time": "2026-05-29T05:06:50+00:00"
}, },
{
"name": "symfony/object-mapper",
"version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/object-mapper.git",
"reference": "f2d118d3ced275117b83acc5b57f6611ab38cd14"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/object-mapper/zipball/f2d118d3ced275117b83acc5b57f6611ab38cd14",
"reference": "f2d118d3ced275117b83acc5b57f6611ab38cd14",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"psr/container": "^2.0"
},
"conflict": {
"symfony/property-access": "<7.2"
},
"require-dev": {
"symfony/property-access": "^7.4|^8.0",
"symfony/var-exporter": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\ObjectMapper\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a way to map an object to another object",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/object-mapper/tree/v8.1.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-17T10:12:54+00:00"
},
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v8.1.0", "version": "v8.1.0",
+1 -1
View File
@@ -30,7 +30,7 @@ return [
TwigExtraBundle::class => ['all' => true], TwigExtraBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
SymfonyCastsVerifyEmailBundle::class => ['all' => true], SymfonyCastsVerifyEmailBundle::class => ['all' => true],
SentryBundle::class => ['prod' => true], SentryBundle::class => ['dev' => true, 'prod' => true],
SymfonycastsSassBundle::class => ['all' => true], SymfonycastsSassBundle::class => ['all' => true],
StimulusBundle::class => ['all' => true], StimulusBundle::class => ['all' => true],
TurboBundle::class => ['all' => true], TurboBundle::class => ['all' => true],
+1 -1
View File
@@ -30,7 +30,7 @@ security:
access_control: access_control:
- { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/backoffice, roles: ROLE_USER } - { path: ^/backoffice, roles: IS_AUTHENTICATED }
when@test: when@test:
security: security:
+7
View File
@@ -1,3 +1,10 @@
when@dev:
sentry:
dsn: 'https://placeholder@placeholder.ingest.sentry.io/0'
options:
spotlight: true
spotlight_url: 'http://spotlight:8969/stream'
when@prod: when@prod:
sentry: sentry:
dsn: '%env(SENTRY_DSN)%' dsn: '%env(SENTRY_DSN)%'
@@ -6,3 +6,4 @@ stof_doctrine_extensions:
default: default:
timestampable: true timestampable: true
softdeleteable: true softdeleteable: true
loggable: true
+2
View File
@@ -1,6 +1,8 @@
twig: twig:
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
form_themes: [ 'bootstrap_5_layout.html.twig' ] form_themes: [ 'bootstrap_5_layout.html.twig' ]
globals:
sentry_dsn: '%env(SENTRY_DSN)%'
when@test: when@test:
twig: twig:
+1
View File
@@ -1518,6 +1518,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* web_profiler?: WebProfilerConfig, * web_profiler?: WebProfilerConfig,
* twig_extra?: TwigExtraConfig, * twig_extra?: TwigExtraConfig,
* symfonycasts_verify_email?: SymfonycastsVerifyEmailConfig, * symfonycasts_verify_email?: SymfonycastsVerifyEmailConfig,
* sentry?: SentryConfig,
* symfonycasts_sass?: SymfonycastsSassConfig, * symfonycasts_sass?: SymfonycastsSassConfig,
* stimulus?: StimulusConfig, * stimulus?: StimulusConfig,
* turbo?: TurboConfig, * turbo?: TurboConfig,
+27 -27
View File
@@ -12,33 +12,33 @@ declare(strict_types=1);
* be used as an "entrypoint" (and passed to the importmap() Twig function). * be used as an "entrypoint" (and passed to the importmap() Twig function).
* *
* The "importmap:require" command can be used to add new entries to this file. * The "importmap:require" command can be used to add new entries to this file.
*
* @return array<string, array{ // Import name as key, description of the imported file as value
* path: string, // Logical, relative or absolute path to the file
* type?: 'js'|'css'|'json', // Type of the file, defaults to 'js'
* entrypoint?: bool, // Whether the file is an entrypoint, for 'js' only
* }|array{
* version: string, // Version of the remote package
* package_specifier?: string, // Remote "package-name/path" specifier, defaults to the import name
* type?: 'js'|'css'|'json',
* entrypoint?: bool,
* }>
*/ */
return [ return [
'quiz' => [ 'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true],
'path' => './assets/quiz.js', 'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true],
'entrypoint' => true, '@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
], 'bootstrap' => ['version' => '5.3.8'],
'backoffice' => [ '@popperjs/core' => ['version' => '2.11.8'],
'path' => './assets/backoffice.js', 'bootstrap/dist/css/bootstrap.min.css' => ['version' => '5.3.8', 'type' => 'css'],
'entrypoint' => true, '@hotwired/stimulus' => ['version' => '3.2.2'],
], '@hotwired/turbo' => ['version' => '8.0.23'],
'@symfony/stimulus-bundle' => [ 'bootstrap-icons/font/bootstrap-icons.min.css' => ['version' => '1.13.1', 'type' => 'css'],
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', '@sentry/browser' => ['version' => '10.63.0'],
], '@sentry/feedback' => ['version' => '10.63.0'],
'bootstrap' => [ '@sentry/core/browser' => ['version' => '10.63.0'],
'version' => '5.3.8', '@sentry/browser-utils' => ['version' => '10.63.0'],
], '@sentry/replay' => ['version' => '10.63.0'],
'@popperjs/core' => [ '@sentry/replay-canvas' => ['version' => '10.63.0'],
'version' => '2.11.8', '@sentry/core' => ['version' => '10.63.0'],
],
'bootstrap/dist/css/bootstrap.min.css' => [
'version' => '5.3.8',
'type' => 'css',
],
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@hotwired/turbo' => [
'version' => '8.0.23',
],
]; ];
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/** Auto-generated Migration: Please modify to your needs! */
final class Version20260705142647 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return 'Add season question bank tables, quiz finalization, Gedmo Loggable log entries, and label colour/slug';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE bank_answer (id UUID NOT NULL, ordering SMALLINT DEFAULT 0 NOT NULL, text VARCHAR(255) NOT NULL, is_right_answer BOOLEAN NOT NULL, bank_question_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_FAB865583CAC40C0 ON bank_answer (bank_question_id)');
$this->addSql('CREATE TABLE bank_question (id UUID NOT NULL, question VARCHAR(255) NOT NULL, reusable BOOLEAN DEFAULT false NOT NULL, season_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_87B753C94EC001D1 ON bank_question (season_id)');
$this->addSql('CREATE TABLE bank_question_question_label (bank_question_id UUID NOT NULL, question_label_id UUID NOT NULL, PRIMARY KEY (bank_question_id, question_label_id))');
$this->addSql('CREATE INDEX IDX_856E26833CAC40C0 ON bank_question_question_label (bank_question_id)');
$this->addSql('CREATE INDEX IDX_856E268350B19F35 ON bank_question_question_label (question_label_id)');
$this->addSql('CREATE TABLE bank_question_usage (id UUID NOT NULL, created TIMESTAMP(0) WITH TIME ZONE NOT NULL, question_id UUID DEFAULT NULL, bank_question_id UUID NOT NULL, quiz_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_775833AD1E27F6BF ON bank_question_usage (question_id)');
$this->addSql('CREATE INDEX IDX_775833AD3CAC40C0 ON bank_question_usage (bank_question_id)');
$this->addSql('CREATE INDEX IDX_775833AD853CD175 ON bank_question_usage (quiz_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_775833AD3CAC40C0853CD175 ON bank_question_usage (bank_question_id, quiz_id)');
$this->addSql('CREATE TABLE ext_log_entries (data JSON DEFAULT NULL, id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(8) NOT NULL, logged_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, object_id VARCHAR(64) DEFAULT NULL, object_class VARCHAR(191) NOT NULL, version INT NOT NULL, username VARCHAR(191) DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX log_class_lookup_idx ON ext_log_entries (object_class)');
$this->addSql('CREATE INDEX log_date_lookup_idx ON ext_log_entries (logged_at)');
$this->addSql('CREATE INDEX log_user_lookup_idx ON ext_log_entries (username)');
$this->addSql('CREATE INDEX log_version_lookup_idx ON ext_log_entries (object_id, object_class, version)');
$this->addSql("CREATE TABLE question_label (id UUID NOT NULL, colour VARCHAR(16) DEFAULT 'secondary' NOT NULL, slug VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL, season_id UUID NOT NULL, PRIMARY KEY (id))");
$this->addSql('CREATE INDEX IDX_3E4C41EC4EC001D1 ON question_label (season_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_3E4C41EC5E237E064EC001D1 ON question_label (name, season_id)');
$this->addSql('CREATE UNIQUE INDEX uq_question_label_slug_season ON question_label (slug, season_id)');
$this->addSql('ALTER TABLE bank_answer ADD CONSTRAINT FK_FAB865583CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE bank_question ADD CONSTRAINT FK_87B753C94EC001D1 FOREIGN KEY (season_id) REFERENCES season (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE bank_question_question_label ADD CONSTRAINT FK_856E26833CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE bank_question_question_label ADD CONSTRAINT FK_856E268350B19F35 FOREIGN KEY (question_label_id) REFERENCES question_label (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_775833AD1E27F6BF FOREIGN KEY (question_id) REFERENCES question (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_775833AD3CAC40C0 FOREIGN KEY (bank_question_id) REFERENCES bank_question (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE bank_question_usage ADD CONSTRAINT FK_775833AD853CD175 FOREIGN KEY (quiz_id) REFERENCES quiz (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE question_label ADD CONSTRAINT FK_3E4C41EC4EC001D1 FOREIGN KEY (season_id) REFERENCES season (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE quiz ADD finalized_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');
}
#[\Override]
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bank_answer DROP CONSTRAINT FK_FAB865583CAC40C0');
$this->addSql('ALTER TABLE bank_question DROP CONSTRAINT FK_87B753C94EC001D1');
$this->addSql('ALTER TABLE bank_question_question_label DROP CONSTRAINT FK_856E26833CAC40C0');
$this->addSql('ALTER TABLE bank_question_question_label DROP CONSTRAINT FK_856E268350B19F35');
$this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_775833AD1E27F6BF');
$this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_775833AD3CAC40C0');
$this->addSql('ALTER TABLE bank_question_usage DROP CONSTRAINT FK_775833AD853CD175');
$this->addSql('ALTER TABLE question_label DROP CONSTRAINT FK_3E4C41EC4EC001D1');
$this->addSql('DROP TABLE bank_answer');
$this->addSql('DROP TABLE bank_question');
$this->addSql('DROP TABLE bank_question_question_label');
$this->addSql('DROP TABLE bank_question_usage');
$this->addSql('DROP TABLE ext_log_entries');
$this->addSql('DROP TABLE question_label');
$this->addSql('ALTER TABLE quiz DROP finalized_at');
}
}
@@ -24,7 +24,7 @@ use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
final class BackofficeController extends AbstractController final class BackofficeController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -15,6 +15,7 @@ use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Elimination; use Tvdt\Entity\Elimination;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType;
use Tvdt\Factory\EliminationFactory; use Tvdt\Factory\EliminationFactory;
final class PrepareEliminationController extends AbstractController final class PrepareEliminationController extends AbstractController
@@ -52,7 +53,7 @@ final class PrepareEliminationController extends AbstractController
return $this->redirectToRoute('tvdt_elimination', ['elimination' => $elimination->id]); return $this->redirectToRoute('tvdt_elimination', ['elimination' => $elimination->id]);
} }
$this->addFlash('success', 'Elimination updated'); $this->addFlash(FlashType::Success, 'Elimination updated');
return $this->redirectToRoute('tvdt_prepare_elimination_view', ['elimination' => $elimination->id]); return $this->redirectToRoute('tvdt_prepare_elimination_view', ['elimination' => $elimination->id]);
} }
@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
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\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType;
use Tvdt\Enum\LabelColour;
use Tvdt\Exception\BankQuestionAlreadyUsedException;
use Tvdt\Exception\BankQuestionIncompleteException;
use Tvdt\Exception\QuizLockedException;
use Tvdt\Form\BankQuestionFormType;
use Tvdt\Repository\BankQuestionRepository;
use Tvdt\Repository\QuestionLabelRepository;
use Tvdt\Repository\QuizRepository;
use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuestionBankService;
#[AsController]
#[IsGranted('IS_AUTHENTICATED')]
class QuestionBankController extends AbstractController
{
public function __construct(
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em,
private readonly BankQuestionRepository $bankQuestionRepository,
private readonly QuestionLabelRepository $questionLabelRepository,
private readonly QuizRepository $quizRepository,
private readonly QuestionBankService $questionBankService,
private readonly SluggerInterface $slugger,
) {}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank',
name: 'tvdt_backoffice_question_bank',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
priority: 10,
)]
public function index(Season $season, Request $request): Response
{
$label = null;
$labelSlug = $request->query->getString('label');
if ('' !== $labelSlug) {
$label = $this->questionLabelRepository->findBySlugAndSeason($labelSlug, $season);
}
return $this->render('backoffice/season.html.twig', [
'season' => $season,
'bankQuestions' => $this->bankQuestionRepository->findBySeason($season, $label),
'assignableQuizzes' => $this->quizRepository->findAssignableForSeason($season),
'activeLabel' => $label,
'labelColours' => LabelColour::cases(),
'activeTab' => 'question-bank',
'template' => 'backoffice/season/tab_question_bank.html.twig',
]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/new',
name: 'tvdt_backoffice_question_bank_new',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
priority: 10,
)]
public function new(Season $season, Request $request): Response
{
$bankQuestion = new BankQuestion();
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
'season' => $season,
'action' => $this->generateUrl('tvdt_backoffice_question_bank_new', ['seasonCode' => $season->seasonCode]),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($bankQuestion);
$season->addBankQuestion($bankQuestion);
$this->em->persist($bankQuestion);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank'));
if ($isTurboFrame) {
return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
}
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
$template = $isTurboFrame
? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig';
$response = $this->render($template, [
'season' => $season,
'form' => $form,
'bankQuestion' => null,
]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/edit',
name: 'tvdt_backoffice_question_bank_edit',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
priority: 10,
)]
public function edit(Season $season, BankQuestion $bankQuestion, Request $request): Response
{
$this->assertSameSeason($season, $bankQuestion->season);
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
'season' => $season,
'action' => $this->generateUrl('tvdt_backoffice_question_bank_edit', [
'seasonCode' => $season->seasonCode,
'bankQuestion' => $bankQuestion->id,
]),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
$this->syncUsagesAfterEdit($bankQuestion);
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
if ($isTurboFrame) {
return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
}
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
$template = $isTurboFrame
? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig';
$response = $this->render($template, [
'season' => $season,
'form' => $form,
'bankQuestion' => $bankQuestion,
]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
}
#[IsCsrfTokenValid('delete_bank_question')]
#[IsGranted(SeasonVoter::DELETE, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/delete',
name: 'tvdt_backoffice_question_bank_delete',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
methods: ['POST'],
priority: 10,
)]
public function delete(Season $season, BankQuestion $bankQuestion): RedirectResponse
{
$this->assertSameSeason($season, $bankQuestion->season);
$hasLockedUsages = $bankQuestion->usages->exists(
static fn (int $key, BankQuestionUsage $usage): bool => $usage->quiz->isLocked,
);
if ($hasLockedUsages) {
$this->addFlash(FlashType::Danger, $this->translator->trans('This question cannot be deleted because it is used in a locked or active quiz'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
$this->em->remove($bankQuestion);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question removed from the question bank'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('assign_bank_question')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/assign',
name: 'tvdt_backoffice_question_bank_assign',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID],
methods: ['POST'],
priority: 10,
)]
public function assign(Season $season, BankQuestion $bankQuestion, Request $request): RedirectResponse
{
$this->assertSameSeason($season, $bankQuestion->season);
$quizId = $request->request->getString('quiz');
if (!Uuid::isValid($quizId)) {
throw new BadRequestHttpException('Invalid quiz');
}
$quiz = $this->em->getRepository(Quiz::class)->find($quizId);
if (!$quiz instanceof Quiz || $quiz->season !== $season) {
throw new BadRequestHttpException('Invalid quiz');
}
$this->denyAccessUnlessGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, $quiz);
try {
$this->questionBankService->assignToQuiz($bankQuestion, $quiz);
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to quiz %quiz%', ['%quiz%' => $quiz->name]));
} catch (QuizLockedException) {
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
} catch (BankQuestionAlreadyUsedException) {
$this->addFlash(FlashType::Danger, $this->translator->trans('This question has already been used'));
} catch (BankQuestionIncompleteException) {
$this->addFlash(FlashType::Warning, $this->translator->trans('This question is incomplete: it needs at least two answers and exactly one correct answer'));
}
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('add_question_label')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/labels',
name: 'tvdt_backoffice_question_bank_labels',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
methods: ['POST'],
priority: 15,
)]
public function addLabel(Season $season, Request $request): RedirectResponse
{
$name = mb_trim($request->request->getString('name'));
if ('' === $name || mb_strlen($name) > 64) {
$this->addFlash(FlashType::Danger, $this->translator->trans('Invalid label name'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
$slug = mb_strtolower($this->slugger->slug($name)->toString());
$colour = LabelColour::tryFrom($request->request->getString('colour')) ?? LabelColour::Gray;
$exists = $season->questionLabels->exists(static fn (int $key, QuestionLabel $label): bool => $label->name === $name);
if (!$exists) {
if ($this->questionLabelRepository->slugExistsForSeason($slug, $season)) {
$this->addFlash(FlashType::Danger, $this->translator->trans('A label with a similar name already exists'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
try {
$newLabel = new QuestionLabel($name);
$newLabel->slug = $slug;
$newLabel->colour = $colour;
$season->addQuestionLabel($newLabel);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Label added'));
} catch (UniqueConstraintViolationException) {
// Concurrent request already inserted the same label; treat as a no-op
}
}
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('delete_question_label')]
#[IsGranted(SeasonVoter::DELETE, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/labels/{labelSlug}/delete',
name: 'tvdt_backoffice_question_bank_label_delete',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'labelSlug' => '[a-z0-9-]+'],
methods: ['POST'],
priority: 15,
)]
public function deleteLabel(Season $season, string $labelSlug): RedirectResponse
{
$label = $this->questionLabelRepository->findBySlugAndSeason($labelSlug, $season);
if (!$label instanceof QuestionLabel) {
throw $this->createNotFoundException();
}
foreach ($label->bankQuestions as $bankQuestion) {
$bankQuestion->removeLabel($label);
}
$this->em->remove($label);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Label removed'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('unassign_bank_question')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/unassign/{usage}',
name: 'tvdt_backoffice_question_bank_unassign',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID],
methods: ['POST'],
priority: 10,
)]
public function unassign(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse
{
$this->assertSameSeason($season, $bankQuestion->season);
if ($usage->bankQuestion !== $bankQuestion) {
throw new NotFoundHttpException();
}
if ($usage->quiz->isLocked) {
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
$this->questionBankService->unassignFromQuiz($usage);
$this->addFlash(FlashType::Success, $this->translator->trans('Question removed from quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('sync_bank_question')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/question-bank/{bankQuestion}/sync/{usage}',
name: 'tvdt_backoffice_question_bank_sync',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'bankQuestion' => Requirement::UUID, 'usage' => Requirement::UUID],
methods: ['POST'],
priority: 10,
)]
public function syncToQuiz(Season $season, BankQuestion $bankQuestion, BankQuestionUsage $usage): RedirectResponse
{
$this->assertSameSeason($season, $bankQuestion->season);
if ($usage->bankQuestion !== $bankQuestion) {
throw new NotFoundHttpException();
}
if ($usage->quiz->isLocked) {
$this->addFlash(FlashType::Danger, $this->translator->trans('This quiz can no longer be altered'));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question synced to quiz %quiz%', ['%quiz%' => $usage->quiz->name]));
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
}
private function assertSameSeason(Season $season, Season $subjectSeason): void
{
if ($season !== $subjectSeason) {
throw new NotFoundHttpException();
}
}
private function applyAnswerOrdering(BankQuestion $bankQuestion): void
{
$ordering = 1;
foreach ($bankQuestion->answers as $answer) {
$answer->ordering = $ordering++;
}
}
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
{
$pendingNames = [];
$synced = false;
foreach ($bankQuestion->usages as $usage) {
if (!$usage->quiz->isLocked) {
$this->questionBankService->syncToQuiz($bankQuestion, $usage);
$synced = true;
} else {
$pendingNames[] = $usage->quiz->name;
}
}
if ($synced) {
$this->em->flush();
}
if ([] !== $pendingNames) {
$this->addFlash(
FlashType::Warning,
$this->translator->trans(
'The question was not synced to finalized quiz(zes): %quizzes%. Use the Sync button to update them.',
['%quizzes%' => implode(', ', $pendingNames)],
),
);
}
}
}
+78 -9
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tvdt\Controller\Backoffice; namespace Tvdt\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Safe\DateTimeImmutable;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -22,13 +23,14 @@ use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType;
use Tvdt\Exception\ErrorClearingQuizException; use Tvdt\Exception\ErrorClearingQuizException;
use Tvdt\Repository\QuizCandidateRepository; use Tvdt\Repository\QuizCandidateRepository;
use Tvdt\Repository\QuizRepository; use Tvdt\Repository\QuizRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class QuizController extends AbstractController class QuizController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -154,7 +156,13 @@ class QuizController extends AbstractController
public function answerMapping(Season $season, Quiz $quiz): Response public function answerMapping(Season $season, Quiz $quiz): Response
{ {
$fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id);
\assert($fetchedQuiz->questions->count() > 0);
if ($fetchedQuiz->questions->isEmpty()) {
$this->addFlash(FlashType::Warning, $this->translator->trans('This quiz has no questions yet'));
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
}
$firstQuestion = $fetchedQuiz->questions->first(); $firstQuestion = $fetchedQuiz->questions->first();
\assert($firstQuestion instanceof Question); \assert($firstQuestion instanceof Question);
@@ -233,7 +241,7 @@ class QuizController extends AbstractController
$this->em->flush(); $this->em->flush();
$this->addFlash('success', $this->translator->trans('Candidate answers saved')); $this->addFlash(FlashType::Success, $this->translator->trans('Candidate answers saved'));
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [ return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [
'seasonCode' => $season->seasonCode, 'seasonCode' => $season->seasonCode,
@@ -250,13 +258,28 @@ class QuizController extends AbstractController
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'], requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'],
methods: ['POST'], methods: ['POST'],
)] )]
public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse public function enableQuiz(Season $season, ?Quiz $quiz, Request $request): RedirectResponse
{ {
if ($quiz instanceof Quiz && !$quiz->isFinalized) {
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz must be finalized before it can be activated'));
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
}
$season->activeQuiz = $quiz; $season->activeQuiz = $quiz;
$this->em->flush(); $this->em->flush();
if ($quiz instanceof Quiz) { if ($quiz instanceof Quiz) {
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]);
}
// When deactivating, stay on the quiz page if one was passed
$previousQuizId = $request->request->getString('redirect_quiz');
if ('' !== $previousQuizId) {
$previousQuiz = $this->em->getRepository(Quiz::class)->find($previousQuizId);
if ($previousQuiz instanceof Quiz && $previousQuiz->season === $season) {
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $previousQuiz->id]);
}
} }
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]); return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
@@ -274,9 +297,55 @@ class QuizController extends AbstractController
{ {
try { try {
$this->quizRepository->clearQuiz($quiz); $this->quizRepository->clearQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz cleared')); $this->addFlash(FlashType::Success, $this->translator->trans('Quiz cleared and no longer finalized'));
} catch (ErrorClearingQuizException) { } catch (ErrorClearingQuizException) {
$this->addFlash('error', $this->translator->trans('Error clearing quiz')); $this->addFlash(FlashType::Danger, $this->translator->trans('Error clearing quiz'));
}
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
#[IsCsrfTokenValid('finalize_quiz')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/finalize',
name: 'tvdt_backoffice_quiz_finalize',
requirements: ['quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function finalizeQuiz(Quiz $quiz): RedirectResponse
{
if ($quiz->questions->isEmpty() || [] !== $quiz->getQuestionErrors()) {
$this->addFlash(FlashType::Warning, $this->translator->trans('The quiz cannot be finalized while it has errors'));
} elseif (!$quiz->isFinalized) {
$quiz->finalizedAt = new DateTimeImmutable();
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz finalized'));
} else {
$this->addFlash(FlashType::Warning, $this->translator->trans('The quiz is already finalized'));
}
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
#[IsCsrfTokenValid('unfinalize_quiz')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/unfinalize',
name: 'tvdt_backoffice_quiz_unfinalize',
requirements: ['quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function unfinalizeQuiz(Quiz $quiz): RedirectResponse
{
if ($quiz->hasStartedCandidates) {
$this->addFlash(FlashType::Danger, $this->translator->trans('The quiz has already been filled in and can no longer be altered'));
} elseif ($quiz->season->activeQuiz === $quiz) {
$this->addFlash(FlashType::Danger, $this->translator->trans('Deactivate the quiz before undoing the finalization'));
} else {
$quiz->finalizedAt = null;
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz is no longer finalized'));
} }
return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); return $this->redirectToRoute('tvdt_backoffice_quiz', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
@@ -294,7 +363,7 @@ class QuizController extends AbstractController
{ {
$this->quizRepository->deleteQuiz($quiz); $this->quizRepository->deleteQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz deleted')); $this->addFlash(FlashType::Success, $this->translator->trans('Quiz deleted'));
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]); return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
} }
@@ -359,7 +428,7 @@ class QuizController extends AbstractController
$this->em->flush(); $this->em->flush();
$this->addFlash('success', $this->translator->trans('Candidate status updated')); $this->addFlash(FlashType::Success, $this->translator->trans('Candidate status updated'));
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
} }
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
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\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType;
use Tvdt\Form\QuestionFormType;
use Tvdt\Security\Voter\SeasonVoter;
#[AsController]
#[IsGranted('IS_AUTHENTICATED')]
class QuizQuestionController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TranslatorInterface $translator,
) {}
#[IsGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, subject: 'question')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/edit',
name: 'tvdt_backoffice_quiz_question_edit',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID, 'question' => Requirement::UUID],
)]
public function edit(Season $season, Quiz $quiz, Question $question, Request $request): Response
{
if ($question->quiz !== $quiz || $quiz->season !== $season) {
throw new NotFoundHttpException();
}
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(QuestionFormType::class, $question, [
'action' => $this->generateUrl('tvdt_backoffice_quiz_question_edit', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
'question' => $question->id,
]),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($question);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
if ($isTurboFrame) {
return new Response('<turbo-frame id="question-modal-frame"></turbo-frame>');
}
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
]);
}
$template = $isTurboFrame
? 'backoffice/quiz/_question_frame.html.twig'
: 'backoffice/quiz/question_form.html.twig';
$response = $this->render($template, [
'season' => $season,
'quiz' => $quiz,
'question' => $question,
'form' => $form,
]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
name: 'tvdt_backoffice_quiz_question_view',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID, 'question' => Requirement::UUID],
)]
public function view(Season $season, Quiz $quiz, Question $question): Response
{
if ($question->quiz !== $quiz || $quiz->season !== $season) {
throw new NotFoundHttpException();
}
return $this->render('backoffice/quiz/_question_detail_frame.html.twig', [
'question' => $question,
]);
}
#[IsCsrfTokenValid('question_reorder')]
#[IsGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, subject: 'quiz')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/questions/reorder',
name: 'tvdt_backoffice_quiz_questions_reorder',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function reorder(Season $season, Quiz $quiz, Request $request): Response
{
if ($quiz->season !== $season) {
throw new NotFoundHttpException();
}
/** @var list<string> $ordering */
$ordering = $request->request->all('ordering');
$questionsById = [];
foreach ($quiz->questions as $question) {
$questionsById[$question->id->toString()] = $question;
}
foreach ($ordering as $questionId) {
if (!isset($questionsById[$questionId])) {
throw new BadRequestHttpException(\sprintf('Unknown question id: %s', $questionId));
}
}
if (\count(array_unique($ordering)) !== \count($questionsById)) {
throw new BadRequestHttpException('Ordering must include every question exactly once');
}
$position = 1;
foreach ($ordering as $questionId) {
$questionsById[$questionId]->ordering = $position++;
}
$this->em->flush();
return new Response('', Response::HTTP_NO_CONTENT);
}
private function applyAnswerOrdering(Question $question): void
{
$ordering = 1;
foreach ($question->answers as $answer) {
$answer->ordering = $ordering++;
}
}
}
+93 -3
View File
@@ -4,13 +4,19 @@ declare(strict_types=1);
namespace Tvdt\Controller\Backoffice; namespace Tvdt\Controller\Backoffice;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController; use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Candidate; use Tvdt\Entity\Candidate;
@@ -24,7 +30,7 @@ use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class SeasonController extends AbstractController class SeasonController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -39,7 +45,39 @@ class SeasonController extends AbstractController
name: 'tvdt_backoffice_season', name: 'tvdt_backoffice_season',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX], requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
)] )]
public function index(Season $season, Request $request): Response public function index(Season $season): Response
{
return $this->render('backoffice/season.html.twig', [
'season' => $season,
'activeTab' => 'tests',
'template' => 'backoffice/season/tab_tests.html.twig',
]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/candidates',
name: 'tvdt_backoffice_season_candidates',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
priority: 10,
)]
public function candidatesTab(Season $season): Response
{
return $this->render('backoffice/season.html.twig', [
'season' => $season,
'activeTab' => 'candidates',
'template' => 'backoffice/season/tab_candidates.html.twig',
]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/settings',
name: 'tvdt_backoffice_season_settings',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
priority: 10,
)]
public function settingsTab(Season $season, Request $request): Response
{ {
$form = $this->createForm(SettingsForm::class, $season->settings); $form = $this->createForm(SettingsForm::class, $season->settings);
@@ -47,11 +85,15 @@ class SeasonController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush(); $this->em->flush();
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
} }
return $this->render('backoffice/season.html.twig', [ return $this->render('backoffice/season.html.twig', [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'activeTab' => 'settings',
'template' => 'backoffice/season/tab_settings.html.twig',
]); ]);
} }
@@ -78,7 +120,7 @@ class SeasonController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]); return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
} }
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]); return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -112,4 +154,52 @@ class SeasonController extends AbstractController
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]); return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/add-blank-quiz',
name: 'tvdt_backoffice_quiz_add_blank',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
priority: 10,
)]
public function addBlankQuiz(Request $request, Season $season): Response
{
$form = $this->createFormBuilder(new Quiz())
->add('name', TextType::class, [
'label' => $this->translator->trans('Quiz name'),
'translation_domain' => false,
'constraints' => [
new NotBlank(),
new Length(max: 64),
],
])
->add('save', SubmitType::class, ['label' => 'Create'])
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var Quiz $quiz */
$quiz = $form->getData();
$quiz->season = $season;
$this->em->persist($quiz);
try {
$this->em->flush();
} catch (UniqueConstraintViolationException) {
$form->get('name')->addError(new FormError($this->translator->trans('A quiz with this name already exists in this season')));
return $this->render('/backoffice/quiz_add_blank.html.twig', ['form' => $form, 'season' => $season]);
}
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
]);
}
return $this->render('/backoffice/quiz_add_blank.html.twig', ['form' => $form, 'season' => $season]);
}
} }
+1 -1
View File
@@ -23,7 +23,7 @@ use Tvdt\Security\Voter\SeasonVoter;
use function Symfony\Component\Translation\t; use function Symfony\Component\Translation\t;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
final class EliminationController extends AbstractController final class EliminationController extends AbstractController
{ {
public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {} public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {}
+2 -1
View File
@@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface; use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
use Tvdt\Entity\User; use Tvdt\Entity\User;
use Tvdt\Enum\FlashType;
use Tvdt\Form\RegistrationFormType; use Tvdt\Form\RegistrationFormType;
use Tvdt\Repository\UserRepository; use Tvdt\Repository\UserRepository;
use Tvdt\Security\EmailVerifier; use Tvdt\Security\EmailVerifier;
@@ -95,7 +96,7 @@ final class RegistrationController extends AbstractController
return $this->redirectToRoute('tvdt_register'); return $this->redirectToRoute('tvdt_register');
} }
$this->addFlash('success', $this->translator->trans('Your email address has been verified.')); $this->addFlash(FlashType::Success->value, $this->translator->trans('Your email address has been verified.'));
return $this->redirectToRoute('tvdt_backoffice_index'); return $this->redirectToRoute('tvdt_backoffice_index');
} }
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Tvdt\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Entity\User;
final class DevFixtures extends Fixture implements FixtureGroupInterface
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
public static function getGroups(): array
{
return ['dev'];
}
public function load(ObjectManager $manager): void
{
$user = new User();
$user->email = 'admin@tijdvoordetest.nl';
$user->password = $this->passwordHasher->hashPassword($user, '12345678');
$user->roles = ['ROLE_ADMIN'];
$manager->persist($user);
$manager->flush();
}
}
+68 -1
View File
@@ -7,9 +7,14 @@ namespace Tvdt\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Safe\DateTimeImmutable;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\Candidate; use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\SeasonSettings; use Tvdt\Entity\SeasonSettings;
@@ -18,6 +23,16 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
{ {
public const string KRTEK_SEASON = 'krtek-seaspm'; public const string KRTEK_SEASON = 'krtek-seaspm';
public const string KRTEK_QUIZ_1 = 'krtek-quiz-1';
public const string KRTEK_QUIZ_2 = 'krtek-quiz-2';
public const string BANK_QUESTION_REUSABLE = 'bank-question-reusable';
public const string BANK_QUESTION_USED = 'bank-question-used';
public const string BANK_QUESTION_UNUSED = 'bank-question-unused';
public static function getGroups(): array public static function getGroups(): array
{ {
return ['test', 'dev']; return ['test', 'dev'];
@@ -47,16 +62,68 @@ final class KrtekFixtures extends Fixture implements FixtureGroupInterface
$quiz1 = $this->createQuiz1($season); $quiz1 = $this->createQuiz1($season);
$season->addQuiz($quiz1); $season->addQuiz($quiz1);
$season->activeQuiz = $quiz1; $season->activeQuiz = $quiz1;
$season->addQuiz($this->createQuiz2($season));
$quiz1->finalizedAt = new DateTimeImmutable();
$quiz2 = $this->createQuiz2($season);
$season->addQuiz($quiz2);
\assert($season->settings instanceof SeasonSettings); \assert($season->settings instanceof SeasonSettings);
$season->settings->confirmAnswers = true; $season->settings->confirmAnswers = true;
$season->settings->showNumbers = true; $season->settings->showNumbers = true;
$this->createQuestionBank($season, $quiz2);
$manager->flush(); $manager->flush();
$this->addReference(self::KRTEK_SEASON, $season); $this->addReference(self::KRTEK_SEASON, $season);
$this->addReference(self::KRTEK_QUIZ_1, $quiz1);
$this->addReference(self::KRTEK_QUIZ_2, $quiz2);
}
private function createQuestionBank(Season $season, Quiz $usedInQuiz): void
{
$location = new QuestionLabel('Locatie');
$location->slug = 'locatie';
$season->addQuestionLabel($location);
$finale = new QuestionLabel('Finale');
$finale->slug = 'finale';
$season->addQuestionLabel($finale);
$reusable = new BankQuestion();
$reusable->question = 'Wie is de Krtek?';
$reusable->reusable = true;
$reusable->addLabel($finale);
$reusable->addAnswer(new BankAnswer('Claudia', true));
$reusable->addAnswer(new BankAnswer('Eelco'));
$reusable->addAnswer(new BankAnswer('Elise'));
$season->addBankQuestion($reusable);
$used = new BankQuestion();
$used->question = 'Waar sliep de Krtek?';
$used->addLabel($location);
$used->addAnswer(new BankAnswer('Boven', true));
$used->addAnswer(new BankAnswer('Beneden'));
$used->addUsage(new BankQuestionUsage($used, $usedInQuiz));
$season->addBankQuestion($used);
$unused = new BankQuestion();
$unused->question = 'Wat at de Krtek als ontbijt?';
$unused->addLabel($location);
$unused->addLabel($finale);
$unused->addAnswer(new BankAnswer('Brood', true));
$unused->addAnswer(new BankAnswer('Yoghurt'));
$unused->addAnswer(new BankAnswer('Niks'));
$season->addBankQuestion($unused);
$this->addReference(self::BANK_QUESTION_REUSABLE, $reusable);
$this->addReference(self::BANK_QUESTION_USED, $used);
$this->addReference(self::BANK_QUESTION_UNUSED, $unused);
} }
private function createQuiz1(Season $season): Quiz private function createQuiz1(Season $season): Quiz
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Tvdt\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\MappedSuperclass]
abstract class AbstractBaseAnswer implements \Stringable
{
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
public int $ordering = 0;
public function __construct(
#[ORM\Column(length: 255)]
public string $text,
#[ORM\Column]
public bool $isRightAnswer = false,
) {}
public function __toString(): string
{
return $this->text;
}
}
+4 -16
View File
@@ -6,14 +6,13 @@ namespace Tvdt\Entity;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use Tvdt\Repository\AnswerRepository; use Tvdt\Repository\AnswerRepository;
#[ORM\Entity(repositoryClass: AnswerRepository::class)] #[ORM\Entity(repositoryClass: AnswerRepository::class)]
class Answer implements \Stringable class Answer extends AbstractBaseAnswer
{ {
#[ORM\Column(type: UuidType::NAME)] #[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
@@ -21,9 +20,6 @@ class Answer implements \Stringable
#[ORM\Id] #[ORM\Id]
public private(set) Uuid $id; public private(set) Uuid $id;
#[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])]
public int $ordering = 0;
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'answers')] #[ORM\ManyToOne(inversedBy: 'answers')]
public Question $question; public Question $question;
@@ -36,12 +32,9 @@ class Answer implements \Stringable
#[ORM\OneToMany(targetEntity: GivenAnswer::class, mappedBy: 'answer', orphanRemoval: true)] #[ORM\OneToMany(targetEntity: GivenAnswer::class, mappedBy: 'answer', orphanRemoval: true)]
public private(set) Collection $givenAnswers; public private(set) Collection $givenAnswers;
public function __construct( public function __construct(string $text, bool $isRightAnswer = false)
#[ORM\Column(length: 255)] {
public string $text, parent::__construct($text, $isRightAnswer);
#[ORM\Column]
public bool $isRightAnswer = false,
) {
$this->candidates = new ArrayCollection(); $this->candidates = new ArrayCollection();
$this->givenAnswers = new ArrayCollection(); $this->givenAnswers = new ArrayCollection();
} }
@@ -57,9 +50,4 @@ class Answer implements \Stringable
{ {
$this->candidates->removeElement($candidate); $this->candidates->removeElement($candidate);
} }
public function __toString(): string
{
return $this->text;
}
} }
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tvdt\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
class BankAnswer extends AbstractBaseAnswer
{
#[Map(if: false)]
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
public private(set) Uuid $id;
#[Map(if: false)]
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'answers')]
public BankQuestion $bankQuestion;
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Tvdt\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Tvdt\Repository\BankQuestionRepository;
#[Gedmo\Loggable(logEntryClass: LogEntry::class)]
#[ORM\Entity(repositoryClass: BankQuestionRepository::class)]
class BankQuestion implements \Stringable
{
#[Map(if: false)]
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
public private(set) Uuid $id;
#[Gedmo\Versioned]
#[ORM\Column(length: 255)]
public string $question;
#[Map(if: false)]
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'bankQuestions')]
public Season $season;
#[Gedmo\Versioned]
#[Map(if: false)]
#[ORM\Column(options: ['default' => false])]
public bool $reusable = false;
/** @var Collection<int, QuestionLabel> */
#[Map(if: false)]
#[ORM\ManyToMany(targetEntity: QuestionLabel::class, inversedBy: 'bankQuestions')]
public private(set) Collection $labels;
/** @var Collection<int, BankAnswer> */
#[Map(if: false)]
#[ORM\OneToMany(targetEntity: BankAnswer::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['ordering' => 'ASC'])]
public private(set) Collection $answers;
/** @var Collection<int, BankQuestionUsage> */
#[Map(if: false)]
#[ORM\OneToMany(targetEntity: BankQuestionUsage::class, mappedBy: 'bankQuestion', cascade: ['persist'], orphanRemoval: true)]
public private(set) Collection $usages;
public function __construct()
{
$this->labels = new ArrayCollection();
$this->answers = new ArrayCollection();
$this->usages = new ArrayCollection();
}
public function addAnswer(BankAnswer $answer): static
{
if (!$this->answers->contains($answer)) {
$this->answers->add($answer);
$answer->bankQuestion = $this;
}
return $this;
}
public function removeAnswer(BankAnswer $answer): static
{
$this->answers->removeElement($answer);
return $this;
}
public function addLabel(QuestionLabel $label): static
{
if (!$this->labels->contains($label)) {
$this->labels->add($label);
}
return $this;
}
public function removeLabel(QuestionLabel $label): static
{
$this->labels->removeElement($label);
return $this;
}
public function addUsage(BankQuestionUsage $usage): static
{
if (!$this->usages->contains($usage)) {
$this->usages->add($usage);
}
return $this;
}
public bool $isUsed {
get => !$this->usages->isEmpty();
}
public bool $canBeAssigned {
get => $this->reusable || !$this->isUsed;
}
/** True when the question is fully complete and can be assigned to a quiz. */
public bool $isCompleteForQuiz {
get => $this->answers->count() >= 2
&& 1 === $this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count();
}
public function isUsedInQuiz(Quiz $quiz): bool
{
return $this->usages->exists(static fn (int $key, BankQuestionUsage $usage): bool => $usage->quiz === $quiz);
}
#[Assert\Callback]
public function validateAnswers(ExecutionContextInterface $context): void
{
if ($this->answers->isEmpty()) {
return;
}
$this->answers->filter(static fn (BankAnswer $answer): bool => $answer->isRightAnswer)->count();
if ($this->answers->count() < 2) {
$context->buildViolation('A question needs at least two answers')
->atPath('answers')
->addViolation();
}
}
public function __toString(): string
{
return $this->question;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tvdt\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\UniqueConstraint(fields: ['bankQuestion', 'quiz'])]
class BankQuestionUsage
{
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
public private(set) Uuid $id;
#[Gedmo\Timestampable(on: 'create')]
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)]
public private(set) \DateTimeImmutable $created;
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[ORM\ManyToOne]
public ?Question $question = null;
public function __construct(
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'usages')]
public private(set) BankQuestion $bankQuestion,
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[ORM\ManyToOne]
public private(set) Quiz $quiz,
) {}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tvdt\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Loggable\Entity\MappedSuperclass\AbstractLogEntry;
use Gedmo\Loggable\Entity\Repository\LogEntryRepository;
/**
* Custom LogEntry that stores change data as JSON (instead of serialized `array`)
* so it works with the PostgreSQL-only DBAL setup in this project.
*
* @extends AbstractLogEntry<object>
*/
#[ORM\Entity(repositoryClass: LogEntryRepository::class)]
#[ORM\Index(name: 'log_class_lookup_idx', columns: ['object_class'])]
#[ORM\Index(name: 'log_date_lookup_idx', columns: ['logged_at'])]
#[ORM\Index(name: 'log_user_lookup_idx', columns: ['username'])]
#[ORM\Index(name: 'log_version_lookup_idx', columns: ['object_id', 'object_class', 'version'])]
#[ORM\Table(name: 'ext_log_entries')]
class LogEntry extends AbstractLogEntry
{
#[ORM\Column(type: Types::JSON, nullable: true)]
#[\Override]
protected $data;
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tvdt\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
use Tvdt\Enum\LabelColour;
use Tvdt\Repository\QuestionLabelRepository;
#[ORM\Entity(repositoryClass: QuestionLabelRepository::class)]
#[ORM\UniqueConstraint(fields: ['name', 'season'])]
#[ORM\UniqueConstraint(name: 'uq_question_label_slug_season', fields: ['slug', 'season'])]
class QuestionLabel implements \Stringable
{
#[ORM\Column(type: UuidType::NAME)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
public private(set) Uuid $id;
#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne(inversedBy: 'questionLabels')]
public Season $season;
/** @var Collection<int, BankQuestion> */
#[ORM\ManyToMany(targetEntity: BankQuestion::class, mappedBy: 'labels')]
public private(set) Collection $bankQuestions;
#[ORM\Column(length: 16, enumType: LabelColour::class, options: ['default' => 'secondary'])]
public LabelColour $colour = LabelColour::Gray;
#[ORM\Column(length: 64)]
public string $slug = '';
public function __construct(
#[ORM\Column(length: 64)]
public string $name,
) {
$this->bankQuestions = new ArrayCollection();
}
public function __toString(): string
{
return $this->name;
}
}
+17
View File
@@ -6,6 +6,7 @@ namespace Tvdt\Entity;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
@@ -40,6 +41,9 @@ class Quiz
#[ORM\Column(nullable: false, options: ['default' => 1])] #[ORM\Column(nullable: false, options: ['default' => 1])]
public int $dropouts = 1; public int $dropouts = 1;
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
public ?\DateTimeImmutable $finalizedAt = null;
/** @var Collection<int, Elimination> */ /** @var Collection<int, Elimination> */
#[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)] #[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['createdAt' => 'DESC'])] #[ORM\OrderBy(['createdAt' => 'DESC'])]
@@ -62,6 +66,19 @@ class Quiz
return $this; return $this;
} }
public bool $isFinalized {
get => $this->finalizedAt instanceof \DateTimeImmutable;
}
public bool $hasStartedCandidates {
get => $this->candidateData->exists(static fn (int $key, QuizCandidate $quizCandidate): bool => $quizCandidate->started instanceof \DateTimeImmutable);
}
/** A locked quiz can no longer be altered: it is either explicitly finalized or a candidate has already started filling it in. */
public bool $isLocked {
get => $this->isFinalized || $this->hasStartedCandidates;
}
public function addElimination(Elimination $elimination): self public function addElimination(Elimination $elimination): self
{ {
$this->eliminations->add($elimination); $this->eliminations->add($elimination);
+32
View File
@@ -51,12 +51,24 @@ class Season
#[ORM\OneToOne(cascade: ['persist', 'remove'])] #[ORM\OneToOne(cascade: ['persist', 'remove'])]
public ?SeasonSettings $settings = null; public ?SeasonSettings $settings = null;
/** @var Collection<int, BankQuestion> */
#[ORM\OneToMany(targetEntity: BankQuestion::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['question' => 'ASC'])]
public private(set) Collection $bankQuestions;
/** @var Collection<int, QuestionLabel> */
#[ORM\OneToMany(targetEntity: QuestionLabel::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
public private(set) Collection $questionLabels;
public function __construct() public function __construct()
{ {
$this->settings = new SeasonSettings(); $this->settings = new SeasonSettings();
$this->quizzes = new ArrayCollection(); $this->quizzes = new ArrayCollection();
$this->candidates = new ArrayCollection(); $this->candidates = new ArrayCollection();
$this->owners = new ArrayCollection(); $this->owners = new ArrayCollection();
$this->bankQuestions = new ArrayCollection();
$this->questionLabels = new ArrayCollection();
} }
public function addQuiz(Quiz $quiz): static public function addQuiz(Quiz $quiz): static
@@ -79,6 +91,26 @@ class Season
return $this; return $this;
} }
public function addBankQuestion(BankQuestion $bankQuestion): static
{
if (!$this->bankQuestions->contains($bankQuestion)) {
$this->bankQuestions->add($bankQuestion);
$bankQuestion->season = $this;
}
return $this;
}
public function addQuestionLabel(QuestionLabel $questionLabel): static
{
if (!$this->questionLabels->contains($questionLabel)) {
$this->questionLabels->add($questionLabel);
$questionLabel->season = $this;
}
return $this;
}
public function addOwner(User $owner): static public function addOwner(User $owner): static
{ {
if (!$this->owners->contains($owner)) { if (!$this->owners->contains($owner)) {
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Tvdt\Enum;
use Symfony\Component\Translation\TranslatableMessage;
enum LabelColour: string
{
case Blue = 'primary';
case Gray = 'secondary';
case Green = 'success';
case Red = 'danger';
case Yellow = 'warning';
case Cyan = 'info';
case White = 'light';
public function label(): TranslatableMessage
{
return match ($this) {
self::Blue => new TranslatableMessage('Blue'),
self::Gray => new TranslatableMessage('Gray'),
self::Green => new TranslatableMessage('Green'),
self::Red => new TranslatableMessage('Red'),
self::Yellow => new TranslatableMessage('Yellow'),
self::Cyan => new TranslatableMessage('Cyan'),
self::White => new TranslatableMessage('White'),
};
}
}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Tvdt\Exception;
class BankQuestionAlreadyUsedException extends \Exception {}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Tvdt\Exception;
class BankQuestionIncompleteException extends \Exception {}
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Tvdt\Exception;
class QuizLockedException extends \Exception {}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @template TAnswer of object
*
* @extends AbstractType<TAnswer>
*/
abstract class AbstractBaseAnswerFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('ordering', HiddenType::class, ['empty_data' => '0'])
->add('text', TextType::class, [
'label' => false,
'attr' => ['placeholder' => 'Answer', 'maxlength' => 255],
])
->add('isRightAnswer', CheckboxType::class, [
'label' => 'Correct',
'required' => false,
])
;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Tvdt\Entity\Answer;
/** @extends AbstractBaseAnswerFormType<Answer> */
class AnswerFormType extends AbstractBaseAnswerFormType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Answer::class,
'empty_data' => static fn (): Answer => new Answer(''),
]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Tvdt\Entity\BankAnswer;
/** @extends AbstractBaseAnswerFormType<BankAnswer> */
class BankAnswerFormType extends AbstractBaseAnswerFormType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BankAnswer::class,
'empty_data' => static fn (): BankAnswer => new BankAnswer(''),
]);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Season;
use Tvdt\Repository\QuestionLabelRepository;
/** @extends AbstractType<BankQuestion> */
class BankQuestionFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var Season $season */
$season = $options['season'];
$builder
->add('question', TextType::class, [
'label' => 'Question',
'attr' => ['maxlength' => 255],
])
->add('reusable', CheckboxType::class, [
'label' => 'Reusable',
'required' => false,
'label_attr' => ['class' => 'checkbox-switch'],
'attr' => ['role' => 'switch', 'switch' => null],
])
->add('labels', EntityType::class, [
'label' => 'Labels',
'class' => QuestionLabel::class,
'multiple' => true,
'expanded' => true,
'required' => false,
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
->createQueryBuilder('l')
->where('l.season = :season')
->orderBy('l.name', 'ASC')
->setParameter('season', $season),
])
->add('answers', CollectionType::class, [
'label' => 'Answers',
'entry_type' => BankAnswerFormType::class,
'entry_options' => ['label' => false],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
])
->add('save', SubmitType::class, [
'label' => 'Save',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BankQuestion::class,
]);
$resolver->setRequired('season');
$resolver->setAllowedTypes('season', Season::class);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Tvdt\Entity\Question;
/** @extends AbstractType<Question> */
class QuestionFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('question', TextType::class, [
'label' => 'Question',
'attr' => ['maxlength' => 255],
])
->add('answers', CollectionType::class, [
'label' => 'Answers',
'entry_type' => AnswerFormType::class,
'entry_options' => ['label' => false],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
])
->add('save', SubmitType::class, [
'label' => 'Save',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Question::class,
]);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Season;
/** @extends ServiceEntityRepository<BankQuestion> */
class BankQuestionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BankQuestion::class);
}
/** @return list<BankQuestion> */
public function findBySeason(Season $season, ?QuestionLabel $label = null): array
{
$queryBuilder = $this->createQueryBuilder('bq')
->where('bq.season = :season')
->orderBy('bq.question', 'ASC')
->setParameter('season', $season);
if ($label instanceof QuestionLabel) {
$queryBuilder
->andWhere(':label member of bq.labels')
->setParameter('label', $label);
}
/** @var list<BankQuestion> $questions */
$questions = $queryBuilder->getQuery()->getResult();
if ([] === $questions) {
return [];
}
// Load each many-to-many/one-to-many collection in a separate query to avoid
// the Cartesian-product row explosion that occurs when joining multiple collections at once.
$this->createQueryBuilder('bq')
->select('partial bq.{id}', 'ba')
->leftJoin('bq.answers', 'ba')
->where('bq.season = :season')
->setParameter('season', $season)
->getQuery()
->getResult();
$this->createQueryBuilder('bq')
->select('partial bq.{id}', 'l')
->leftJoin('bq.labels', 'l')
->where('bq.season = :season')
->setParameter('season', $season)
->getQuery()
->getResult();
$this->createQueryBuilder('bq')
->select('partial bq.{id}', 'u', 'uq')
->leftJoin('bq.usages', 'u')
->leftJoin('u.quiz', 'uq')
->where('bq.season = :season')
->setParameter('season', $season)
->getQuery()
->getResult();
return $questions;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Season;
/** @extends ServiceEntityRepository<QuestionLabel> */
class QuestionLabelRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, QuestionLabel::class);
}
public function findBySlugAndSeason(string $slug, Season $season): ?QuestionLabel
{
return $this->findOneBy(['slug' => $slug, 'season' => $season]);
}
public function slugExistsForSeason(string $slug, Season $season, ?QuestionLabel $excluding = null): bool
{
$qb = $this->createQueryBuilder('l')
->where('l.slug = :slug')
->andWhere('l.season = :season')
->setParameter('slug', $slug)
->setParameter('season', $season);
if ($excluding instanceof QuestionLabel) {
$qb->andWhere('l.id != :id')->setParameter('id', $excluding->id);
}
return (int) $qb->select('COUNT(l.id)')->getQuery()->getSingleScalarResult() > 0;
}
}
+42 -4
View File
@@ -12,6 +12,7 @@ use Safe\Exceptions\DatetimeException;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use Tvdt\Dto\Result; use Tvdt\Dto\Result;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Exception\ErrorClearingQuizException; use Tvdt\Exception\ErrorClearingQuizException;
/** @extends ServiceEntityRepository<Quiz> */ /** @extends ServiceEntityRepository<Quiz> */
@@ -22,6 +23,29 @@ class QuizRepository extends ServiceEntityRepository
parent::__construct($registry, Quiz::class); parent::__construct($registry, Quiz::class);
} }
/**
* Quizzes of the season that can still receive bank questions:
* not finalized and not started by any candidate.
*
* @return list<Quiz>
*/
public function findAssignableForSeason(Season $season): array
{
/* @var list<Quiz> */
return $this->getEntityManager()->createQuery(<<<DQL
select q from Tvdt\Entity\Quiz q
where q.season = :season
and q.finalizedAt is null
and not exists (
select 1 from Tvdt\Entity\QuizCandidate qc
where qc.quiz = q and qc.started is not null
)
order by q.id asc
DQL)
->setParameter('season', $season)
->getResult();
}
/** @throws ErrorClearingQuizException */ /** @throws ErrorClearingQuizException */
public function clearQuiz(Quiz $quiz): void public function clearQuiz(Quiz $quiz): void
{ {
@@ -48,6 +72,20 @@ class QuizRepository extends ServiceEntityRepository
DQL) DQL)
->setParameter('quiz', $quiz) ->setParameter('quiz', $quiz)
->execute(); ->execute();
$em->createQuery(<<<DQL
delete from Tvdt\Entity\BankQuestionUsage bqu
where bqu.quiz = :quiz
DQL)
->setParameter('quiz', $quiz)
->execute();
$em->createQuery(<<<DQL
update Tvdt\Entity\Quiz q set q.finalizedAt = null
where q = :quiz
DQL)
->setParameter('quiz', $quiz)
->execute();
} }
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
catch (\Throwable $throwable) { catch (\Throwable $throwable) {
@@ -113,8 +151,8 @@ class QuizRepository extends ServiceEntityRepository
{ {
return $this->getEntityManager()->createQuery(<<<dql return $this->getEntityManager()->createQuery(<<<dql
select q, qz, a from Tvdt\Entity\Quiz q select q, qz, a from Tvdt\Entity\Quiz q
join q.questions qz left join q.questions qz
join qz.answers a left join qz.answers a
where q.id = :id where q.id = :id
dql)->setParameter('id', $id)->getSingleResult(); dql)->setParameter('id', $id)->getSingleResult();
} }
@@ -127,8 +165,8 @@ class QuizRepository extends ServiceEntityRepository
{ {
return $this->getEntityManager()->createQuery(<<<dql return $this->getEntityManager()->createQuery(<<<dql
select q, qz, a, ac, s, sc, qc from Tvdt\Entity\Quiz q select q, qz, a, ac, s, sc, qc from Tvdt\Entity\Quiz q
join q.questions qz left join q.questions qz
join qz.answers a left join qz.answers a
left join a.candidates ac left join a.candidates ac
join q.season s join q.season s
left join s.candidates sc left join s.candidates sc
+29 -6
View File
@@ -8,14 +8,16 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Candidate; use Tvdt\Entity\Candidate;
use Tvdt\Entity\Elimination; use Tvdt\Entity\Elimination;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
/** @extends Voter<string, Season|Elimination|Quiz|Candidate|Answer|Question> */ /** @extends Voter<string, Season|Elimination|Quiz|Candidate|Answer|Question|BankQuestion|QuestionLabel> */
final class SeasonVoter extends Voter final class SeasonVoter extends Voter
{ {
public const string EDIT = 'SEASON_EDIT'; public const string EDIT = 'SEASON_EDIT';
@@ -24,15 +26,19 @@ final class SeasonVoter extends Voter
public const string DELETE = 'SEASON_DELETE'; public const string DELETE = 'SEASON_DELETE';
public const string MODIFY_QUIZ_CONTENT = 'QUIZ_MODIFY_CONTENT';
protected function supports(string $attribute, mixed $subject): bool protected function supports(string $attribute, mixed $subject): bool
{ {
return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true) return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION, self::MODIFY_QUIZ_CONTENT], true)
&& ( && (
$subject instanceof Answer $subject instanceof Answer
|| $subject instanceof BankQuestion
|| $subject instanceof Candidate || $subject instanceof Candidate
|| $subject instanceof Elimination || $subject instanceof Elimination
|| $subject instanceof Season || $subject instanceof Season
|| $subject instanceof Question || $subject instanceof Question
|| $subject instanceof QuestionLabel
|| $subject instanceof Quiz || $subject instanceof Quiz
); );
} }
@@ -44,19 +50,36 @@ final class SeasonVoter extends Voter
return false; return false;
} }
if ($user->isAdmin) {
return true;
}
$season = match (true) { $season = match (true) {
$subject instanceof Answer => $subject->question->quiz->season, $subject instanceof Answer => $subject->question->quiz->season,
$subject instanceof Elimination, $subject instanceof Elimination,
$subject instanceof Question => $subject->quiz->season, $subject instanceof Question => $subject->quiz->season,
$subject instanceof BankQuestion,
$subject instanceof Candidate, $subject instanceof Candidate,
$subject instanceof QuestionLabel,
$subject instanceof Quiz => $subject->season, $subject instanceof Quiz => $subject->season,
$subject instanceof Season => $subject, $subject instanceof Season => $subject,
}; };
if (self::MODIFY_QUIZ_CONTENT === $attribute) {
$quiz = match (true) {
$subject instanceof Answer => $subject->question->quiz,
$subject instanceof Question => $subject->quiz,
$subject instanceof Quiz => $subject,
default => null,
};
if (!$quiz instanceof Quiz || $quiz->isLocked) {
return false;
}
return $user->isAdmin || $season->isOwner($user);
}
if ($user->isAdmin) {
return true;
}
return match ($attribute) { return match ($attribute) {
self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user), self::EDIT, self::DELETE, self::ELIMINATION => $season->isOwner($user),
default => false, default => false,
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Tvdt\Service;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
use Tvdt\Entity\Answer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Exception\BankQuestionAlreadyUsedException;
use Tvdt\Exception\BankQuestionIncompleteException;
use Tvdt\Exception\QuizLockedException;
final readonly class QuestionBankService
{
public function __construct(
private EntityManagerInterface $entityManager,
private ObjectMapperInterface $objectMapper,
) {}
/**
* Copy a bank question (with its answers) into a quiz and record the usage.
*
* @throws QuizLockedException when the quiz is finalized or already filled in
* @throws BankQuestionAlreadyUsedException when the question is single-use and used, or already in this quiz
* @throws BankQuestionIncompleteException when the question lacks ≥2 answers or a correct answer
*/
public function assignToQuiz(BankQuestion $bankQuestion, Quiz $quiz): void
{
if ($bankQuestion->season !== $quiz->season) {
throw new \InvalidArgumentException('Bank question and quiz belong to different seasons');
}
if ($quiz->isLocked) {
throw new QuizLockedException();
}
if (!$bankQuestion->isCompleteForQuiz) {
throw new BankQuestionIncompleteException();
}
$this->entityManager->wrapInTransaction(function () use ($bankQuestion, $quiz): void {
// Pessimistic write lock serialises concurrent assignment attempts for the same BankQuestion
$this->entityManager->lock($bankQuestion, LockMode::PESSIMISTIC_WRITE);
if (!$bankQuestion->canBeAssigned || $bankQuestion->isUsedInQuiz($quiz)) {
throw new BankQuestionAlreadyUsedException();
}
$maxOrdering = 0;
foreach ($quiz->questions as $existingQuestion) {
$maxOrdering = max($maxOrdering, $existingQuestion->ordering);
}
/** @var Question $question */
$question = $this->objectMapper->map($bankQuestion, Question::class);
$question->ordering = $maxOrdering + 1;
foreach ($bankQuestion->answers as $bankAnswer) {
/** @var Answer $answer */
$answer = $this->objectMapper->map($bankAnswer, Answer::class);
$question->addAnswer($answer);
}
$quiz->addQuestion($question);
$usage = new BankQuestionUsage($bankQuestion, $quiz);
$usage->question = $question;
$bankQuestion->addUsage($usage);
$this->entityManager->persist($question);
$this->entityManager->flush();
});
}
/**
* Propagate bank question edits to a quiz copy.
* Only safe on quizzes where no candidate has started (no GivenAnswers exist yet).
*/
public function syncToQuiz(BankQuestion $bankQuestion, BankQuestionUsage $usage): void
{
$question = $usage->question;
if (!$question instanceof Question) {
return;
}
$question->question = $bankQuestion->question;
// Replace answers (safe: no started candidates means no GivenAnswers)
foreach ($question->answers->toArray() as $existingAnswer) {
$question->answers->removeElement($existingAnswer);
$this->entityManager->remove($existingAnswer);
}
foreach ($bankQuestion->answers as $bankAnswer) {
/** @var Answer $answer */
$answer = $this->objectMapper->map($bankAnswer, Answer::class);
$question->addAnswer($answer);
}
}
/** Remove the quiz copy created by this usage and delete the usage record. */
public function unassignFromQuiz(BankQuestionUsage $usage): void
{
$question = $usage->question;
if ($question instanceof Question) {
$question->quiz->questions->removeElement($question);
$this->entityManager->remove($question);
}
$usage->bankQuestion->usages->removeElement($usage);
$this->entityManager->remove($usage);
$this->entityManager->flush();
}
}
+5
View File
@@ -1,5 +1,10 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('backoffice') }}{% endblock %} {% block importmap %}{{ importmap('backoffice') }}{% endblock %}
{% block sentry_loader %}{% endblock %}
{% block head_extra %}
<meta name="sentry-dsn" content="{{ sentry_dsn }}">
<meta name="user-email" content="{{ app.user ? app.user.userIdentifier : '' }}">
{% endblock %}
{% block title %}Tijd voor de test | {% endblock %} {% block title %}Tijd voor de test | {% endblock %}
{% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %} {% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/index.html.twig',
'backoffice/help/nl/index.html.twig',
]) }}
@@ -0,0 +1,10 @@
<h6>Aan de slag</h6>
<p>Elk seizoen groepeert één spel met alle bijbehorende testen en kandidaten. De seizoenscode is de link die kandidaten gebruiken om een test te starten, en is alleen actief als er een actieve test is.</p>
<h6>Globale werkwijze</h6>
<ol>
<li><strong>Seizoen aanmaken</strong> en kandidaten toevoegen</li>
<li><strong>Test aanmaken</strong> via Excel of de vragenbank</li>
<li>Test <strong>afronden</strong> en <strong>activeren</strong></li>
<li>Kandidaten laten <strong>deelnemen</strong> (eigen apparaat of gedeelde laptop)</li>
<li><strong>Resultaten</strong> bekijken en eliminatie starten</li>
</ol>
@@ -0,0 +1,3 @@
<h6>Eliminatie voorbereiden</h6>
<p>Kies voor elke kandidaat een kleur: <strong>groen</strong> betekent veilig, <strong>rood</strong> betekent geëlimineerd.</p>
<p>Gebruik <strong>Opslaan en starten</strong> om de eliminatie direct af te spelen, of sla eerst op en start later via het tabblad Resultaten.</p>
@@ -0,0 +1,8 @@
<h6>Test importeren via Excel</h6>
<p>Upload een Excel-bestand met de vragen voor deze test. Na het uploaden kun je de vragen bekijken, controleren en de test afronden.</p>
<h6>Verwacht formaat</h6>
<ul>
<li>Eerste kolom: de vraagtekst</li>
<li>Volgende kolommen: de antwoordopties</li>
<li>Markeer het juiste antwoord met <strong>WAAR</strong> (Nederlandstalige Excel) of <strong>TRUE</strong> (Engelstalige Excel), alle andere antwoorden zet je op ONWAAR/FALSE</li>
</ul>
@@ -0,0 +1,3 @@
<h6>Lege test aanmaken</h6>
<p>Maak een lege test aan en voeg vragen toe vanuit de vragenbank. Handig als je vragen hergebruikt of ze van tevoren in de bank hebt klaargezet.</p>
<p>Na het aanmaken open je de test, voeg je vragen toe via het tabblad Overzicht en ronde je de test af voordat je hem activeert.</p>
@@ -0,0 +1,3 @@
<h6>Antwoorden invullen</h6>
<p>Gebruik dit formulier om antwoorden aan kandidaten toe te wijzen. Op deze manier kunnen er statistieken gemaakt worden hoe verdacht kandidaten zijn.</p>
<p>Navigeer met de knoppen Vorige en Volgende tussen vragen. Vink per kandidaat het gegeven antwoord aan en sla op.</p>
@@ -0,0 +1,6 @@
<h6>Kandidaten laten deelnemen</h6>
<p>Kandidaten kunnen de test invullen via hun eigen apparaat, een of meerdere gedeelde laptops, of een mix daarvan.</p>
<p><strong>Eigen apparaat:</strong> Deel de seizoenscode. Elke kandidaat bezoekt de site op zijn of haar telefoon of laptop, voert de eigen naam in en start de test.</p>
<p><strong>Gedeelde laptop(s):</strong> Open de naamsinvoerpagina van tevoren op een of meerdere laptops. Elke kandidaat typt zijn of haar naam en start. Na afloop kan de volgende kandidaat hetzelfde doen op dezelfde of een andere laptop.</p>
<h6>Status</h6>
<p>Deactiveer een kandidaat als deze de test niet hoeft te maken, bijvoorbeeld na eerder uitgeschakeld zijn. Deactivering is per test en heeft geen invloed op andere testen.</p>
@@ -0,0 +1,5 @@
<h6>Overzicht &amp; afronden</h6>
<p>Vragen met een rode markering in de lijst hiernaast bevatten een fout. Herstel deze vóór het afronden.</p>
<p><strong>Afronden</strong> vergrendelt de test voor bewerking en maakt hem klaar voor kandidaten. Daarna kun je hem activeren.</p>
<p><strong>Activeren</strong> stelt de test beschikbaar aan kandidaten. Er kan maar één test tegelijk actief zijn, activeer de volgende pas als iedereen de huidige heeft afgerond.</p>
<p><strong>Test wissen</strong> verwijdert alle gegeven antwoorden en heft het afronden op, zodat je de test opnieuw kunt bewerken en uitvoeren.</p>
@@ -0,0 +1,4 @@
<h6>Vraag toevoegen</h6>
<p>Voer de vraag in en voeg minimaal twee antwoordopties toe. Markeer precies één antwoord als correct.</p>
<p>Gebruik labels om vragen te organiseren in de vragenbank, bijvoorbeeld per aflevering of type vraag.</p>
<p>Markeer een vraag als <em>herbruikbaar</em> als deze in meerdere testen mag voorkomen, anders kan een vraag maar aan één test worden gekoppeld.</p>
@@ -0,0 +1,5 @@
<h6>Resultaten</h6>
<p>De tabel toont het eindresultaat per kandidaat gesorteerd op score. Rode rijen zijn de kandidaten met de laagste score die risico lopen op eliminatie.</p>
<p><strong>Jokers</strong> voeg je toe voor goede of foute vragen (halve punten zijn mogelijk).</p>
<p><strong>Straftijd</strong> is tijdstraf in seconden en wordt meegewogen bij gelijke score. Let op: Een positief getal is een straf en een negatief getal is een bonus.</p>
<p>Via <strong>Eliminatie voorbereiden</strong> stel je de schermkleuren handmatig in en start je de eliminatie.</p>
@@ -0,0 +1,3 @@
<h6>Nieuw seizoen</h6>
<p>Een seizoen groepeert alle testen en kandidaten voor één spel. Geef het seizoen een herkenbare naam, de seizoenscode wordt automatisch gegenereerd.</p>
<p>Na het aanmaken voeg je kandidaten toe en maak je testen aan via de seizoenpagina.</p>
@@ -0,0 +1,3 @@
<h6>Kandidaten toevoegen</h6>
<p>Voer één naam per regel in. Dit zijn de spelers die deelnemen aan dit seizoen.</p>
<p>Je kunt later altijd nog kandidaten toevoegen via het tabblad Kandidaten. Gebruik dezelfde schrijfwijze van namen die je in het spel gebruikt.</p>
@@ -0,0 +1,3 @@
<h6>Kandidaten</h6>
<p>Dit zijn de spelers van dit seizoen. Voeg alle deelnemers toe voordat je de eerste test start, kandidaten worden automatisch aan nieuwe testen gekoppeld.</p>
<p>Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.</p>
@@ -0,0 +1,3 @@
<h6>Vragenbank</h6>
<p>De vragenbank is een bibliotheek met vragen die aan meerdere testen kunnen worden gekoppeld. Markeer een vraag als <em>herbruikbaar</em> als deze in meerdere testen mag voorkomen (bijv. "Wie is de Mol?").</p>
<p>Na het bewerken van een vraag in de bank worden testen die de vraag al bevatten <strong>niet</strong> automatisch bijgewerkt, gebruik de synchronisatieknop (↻) naast een test om de meest recente versie door te zetten.</p>
@@ -0,0 +1,3 @@
<h6>Seizoensinstellingen</h6>
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p>
<p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p>
@@ -0,0 +1,11 @@
<h6>Testen beheren</h6>
<p>Voeg een test toe vanuit een Excel-bestand of maak een lege test aan en vul deze via de vragenbank. Open daarna de test om hem te bekijken en af te ronden.</p>
<p>Een test moet eerst <strong>afgerond</strong> zijn voordat je hem kunt activeren. Slechts één test kan tegelijk actief zijn, namelijk de test die kandidaten op dat moment kunnen invullen.</p>
<h6>Volgorde van werken</h6>
<ol>
<li>Test aanmaken (Excel of leeg)</li>
<li>Vragen controleren en test afronden</li>
<li>Test activeren</li>
<li>Kandidaten laten deelnemen</li>
<li>Resultaten bekijken en eliminatie voorbereiden</li>
</ol>
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/prepare_elimination.html.twig',
'backoffice/help/nl/prepare_elimination.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/quiz_add.html.twig',
'backoffice/help/nl/quiz_add.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/quiz_add_blank.html.twig',
'backoffice/help/nl/quiz_add_blank.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/quiz_answer_mapping.html.twig',
'backoffice/help/nl/quiz_answer_mapping.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/quiz_candidates.html.twig',
'backoffice/help/nl/quiz_candidates.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/quiz_overview.html.twig',
'backoffice/help/nl/quiz_overview.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/quiz_question_bank_form.html.twig',
'backoffice/help/nl/quiz_question_bank_form.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/quiz_result.html.twig',
'backoffice/help/nl/quiz_result.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/season_add.html.twig',
'backoffice/help/nl/season_add.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/season_add_candidates.html.twig',
'backoffice/help/nl/season_add_candidates.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/season_candidates.html.twig',
'backoffice/help/nl/season_candidates.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/season_question_bank.html.twig',
'backoffice/help/nl/season_question_bank.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/season_settings.html.twig',
'backoffice/help/nl/season_settings.html.twig',
]) }}
@@ -0,0 +1,4 @@
{{ include([
'backoffice/help/' ~ app.request.locale ~ '/season_tests.html.twig',
'backoffice/help/nl/season_tests.html.twig',
]) }}
+7
View File
@@ -11,6 +11,8 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="row">
<div class="col-md-8 col-12">
<div class="d-flex flex-row align-items-center mb-3"> <div class="d-flex flex-row align-items-center mb-3">
<h2 class="mb-0 pe-2"> <h2 class="mb-0 pe-2">
{{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }} {{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }}
@@ -60,4 +62,9 @@
{% else %} {% else %}
{{ 'You have no seasons yet.'|trans }} {{ 'You have no seasons yet.'|trans }}
{% endif %} {% endif %}
</div>
<div class="col-md-4 col-12">
{{ include('backoffice/help/index.html.twig') }}
</div>
</div>
{% endblock %} {% endblock %}
@@ -0,0 +1,16 @@
{% macro answer_row(answerForm) %}
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item>
{{ form_widget(answerForm.ordering) }}
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span>
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
<div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
<button type="button"
class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}"
title="{{ 'Toggle correct answer'|trans }}"
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
<i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i>
</button>
<button type="button" tabindex="-1" class="btn btn-sm btn-outline-danger"
data-action="bo--form-collection#removeItem"><i class="bi bi-trash"></i></button>
</div>
{% endmacro %}
@@ -40,7 +40,7 @@
</form> </form>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p class="mb-3">{{ 'Help text for preparing elimination'|trans }}</p> {{ include('backoffice/help/prepare_elimination.html.twig') }}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,37 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{{ form_row(form.question) }}
{{ form_row(form.reusable) }}
{{ form_row(form.labels) }}
<div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
{% if isModal ?? false %}
{% do form.save.setRendered %}
{% endif %}
{{ form_end(form) }}
{% if isModal ?? false %}
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</template>
{% endif %}
@@ -0,0 +1,34 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
<turbo-frame id="bank-question-modal-frame">
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="modal-body">
{{ form_row(form.question) }}
{{ form_row(form.reusable) }}
{{ form_row(form.labels) }}
<div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
</div>
{{ form_end(form) }}
</turbo-frame>
@@ -0,0 +1,27 @@
{% extends 'backoffice/base.html.twig' %}
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{% block title %}{{ parent() }}{{ 'Question bank'|trans }}{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_index') }}">{{ 'Home'|trans }}</a></li>
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ season.name }}</a></li>
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_question_bank', {seasonCode: season.seasonCode}) }}">{{ 'Question bank'|trans }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</li>
</ol>
</nav>
{% endblock %}
{% block body %}
<div class="row">
<div class="col-md-6 col-12">
<h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2>
{{ include('backoffice/question_bank/_form_body.html.twig') }}
</div>
<div class="col-md-6 col-12">
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
</div>
</div>
{% endblock body %}
@@ -0,0 +1,21 @@
<p class="fw-semibold mb-3">{{ question.question }}</p>
<ul class="list-group">
{% for answer in question.answers %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span{% if answer.isRightAnswer %} class="fw-semibold"{% endif %}>
{% if answer.isRightAnswer %}<i class="bi bi-check-circle-fill text-success me-1"></i>{% endif %}
{{ answer.text }}
</span>
{% if answer.candidates|length > 0 %}
<span class="text-muted small">{{ answer.candidates|map(c => c.name)|join(', ') }}</span>
{% endif %}
</li>
{% else %}
<li class="list-group-item text-muted">{{ 'There are no answers for this question'|trans }}</li>
{% endfor %}
</ul>
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</template>
@@ -0,0 +1,23 @@
<turbo-frame id="question-modal-frame">
<div class="modal-body">
<p class="fw-semibold mb-3">{{ question.question }}</p>
<ul class="list-group">
{% for answer in question.answers %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span{% if answer.isRightAnswer %} class="fw-semibold"{% endif %}>
{% if answer.isRightAnswer %}<i class="bi bi-check-circle-fill text-success me-1"></i>{% endif %}
{{ answer.text }}
</span>
{% if answer.candidates|length > 0 %}
<span class="text-muted small">{{ answer.candidates|map(c => c.name)|join(', ') }}</span>
{% endif %}
</li>
{% else %}
<li class="list-group-item text-muted">{{ 'There are no answers for this question'|trans }}</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</div>
</turbo-frame>
@@ -0,0 +1,35 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{{ form_row(form.question) }}
<div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
{% if isModal ?? false %}
{% do form.save.setRendered %}
{% endif %}
{{ form_end(form) }}
{% if isModal ?? false %}
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</template>
{% endif %}
@@ -0,0 +1,32 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
<turbo-frame id="question-modal-frame">
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="modal-body">
{{ form_row(form.question) }}
<div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
</div>
{{ form_end(form) }}
</turbo-frame>
@@ -0,0 +1,27 @@
{% extends 'backoffice/base.html.twig' %}
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{% block title %}{{ parent() }}{{ 'Edit question'|trans }}{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_index') }}">{{ 'Home'|trans }}</a></li>
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ season.name }}</a></li>
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_quiz_overview', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ quiz.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ 'Edit question'|trans }}</li>
</ol>
</nav>
{% endblock %}
{% block body %}
<div class="row">
<div class="col-md-6 col-12">
<h2 class="mb-3">{{ 'Edit question'|trans }}</h2>
{{ include('backoffice/quiz/_question_form_body.html.twig') }}
</div>
<div class="col-md-6 col-12">
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
</div>
</div>
{% endblock body %}
@@ -1,3 +1,5 @@
<div class="row">
<div class="col-xl-9 col-12">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
{% set questions = quiz.questions %} {% set questions = quiz.questions %}
@@ -64,3 +66,8 @@
</table> </table>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button> <button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</form> </form>
</div>
<div class="col-xl-3 col-12">
{{ include('backoffice/help/quiz_answer_mapping.html.twig') }}
</div>
</div>
@@ -1,3 +1,5 @@
<div class="row">
<div class="col-md-8 col-12">
<h4 class="mb-3">{{ 'Candidates'|trans }}</h4> <h4 class="mb-3">{{ 'Candidates'|trans }}</h4>
<table class="table table-hover mb-3"> <table class="table table-hover mb-3">
<thead> <thead>
@@ -50,3 +52,8 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<div class="col-md-4 col-12">
{{ include('backoffice/help/quiz_candidates.html.twig') }}
</div>
</div>
+104 -40
View File
@@ -22,13 +22,24 @@
</div> </div>
{% endmacro %} {% endmacro %}
<div data-controller="bo--quiz"> <div class="row">
<h4 class="mb-3">{{ 'Quick actions'|trans }}</h4> <div class="col-md-8 col-12" data-controller="bo--quiz">
<h4 class="mb-3">
{{ 'Quick actions'|trans }}
{% if quiz.isFinalized %}
<span class="badge text-bg-success">{{ 'Finalized'|trans }}</span>
{% elseif quiz.isLocked %}
<span class="badge text-bg-warning">{{ 'Locked (answers given)'|trans }}</span>
{% else %}
<span class="badge text-bg-secondary">{{ 'Draft'|trans }}</span>
{% endif %}
</h4>
<div class="mb-3 btn-group"> <div class="mb-3 btn-group">
{% if quiz is same as (season.activeQuiz) %} {% if quiz is same as (season.activeQuiz) %}
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST"> <form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}"> <input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
<input type="hidden" name="redirect_quiz" value="{{ quiz.id }}">
<button type="submit" class="btn btn-secondary rounded-0 rounded-start"> <button type="submit" class="btn btn-secondary rounded-0 rounded-start">
{{ 'Deactivate Quiz'|trans }} {{ 'Deactivate Quiz'|trans }}
</button> </button>
@@ -36,65 +47,114 @@
{% else %} {% else %}
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST"> <form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}"> <input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
<button type="submit" class="btn btn-primary rounded-0 rounded-start"> <button type="submit" class="btn btn-primary rounded-0 rounded-start"
{% if not quiz.isFinalized %}disabled data-bs-toggle="tooltip"
title="{{ 'The quiz must be finalized before it can be activated'|trans }}"{% endif %}>
{{ 'Make active'|trans }} {{ 'Make active'|trans }}
</button> </button>
</form> </form>
{% endif %} {% endif %}
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz"> {% if not quiz.isFinalized %}
<form action="{{ path('tvdt_backoffice_quiz_finalize', {quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('finalize_quiz') }}">
<button type="submit" class="btn btn-success rounded-0"
data-bs-toggle="tooltip"
title="{{ 'Locks the quiz so it can no longer be edited and makes it ready for candidates to take.'|trans }}">
{{ 'Finalize'|trans }}
</button>
</form>
{% elseif not quiz.hasStartedCandidates and quiz is not same as (season.activeQuiz) %}
<form action="{{ path('tvdt_backoffice_quiz_unfinalize', {quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('unfinalize_quiz') }}">
<button type="submit" class="btn btn-outline-success rounded-0"
data-bs-toggle="tooltip"
title="{{ 'Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.'|trans }}">
{{ 'Undo finalization'|trans }}
</button>
</form>
{% endif %}
<button class="btn btn-danger rounded-0" data-action="click->bo--quiz#clearQuiz">
{{ 'Clear Quiz...'|trans }} {{ 'Clear Quiz...'|trans }}
</button> </button>
<button class="btn btn-danger rounded-0 rounded-end" data-action="click->bo--quiz#deleteQuiz"> <button class="btn btn-danger rounded-0" data-action="click->bo--quiz#deleteQuiz">
{{ 'Delete Quiz...'|trans }} {{ 'Delete Quiz...'|trans }}
</button> </button>
</div> <a class="btn btn-secondary rounded-0 rounded-end"
<a class="btn btn-outline-secondary mb-3"
href="{{ path('tvdt_backoffice_quiz_export', {quiz: quiz.id}) }}"> href="{{ path('tvdt_backoffice_quiz_export', {quiz: quiz.id}) }}">
{{ 'Export to XLSX'|trans }} {{ 'Export to XLSX'|trans }}
</a> </a>
</div>
<h4 class="mb-3">{{ 'Questions'|trans }}</h4> <div data-controller="bo--question-list bo--modal"
<div class="accordion"> data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd"
data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
data-bo--question-list-can-modify-value="{{ is_granted('QUIZ_MODIFY_CONTENT', quiz) ? 'true' : 'false' }}"
data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}"
data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}"
data-bo--question-list-error-hint-value="{{ 'Refresh the page to try again.'|trans }}">
<h4 class="mb-3 d-flex align-items-center gap-2">
{{ 'Questions'|trans }}
<span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span>
</h4>
<div data-bo--question-list-target="list">
{%~ for question in quiz.questions ~%} {%~ for question in quiz.questions ~%}
<div class="accordion-item"> <div class="card mb-2"
<h2 class="accordion-header"> data-bo--question-list-target="item"
<button class="accordion-button collapsed" data-question-id="{{ question.id }}">
type="button" <div class="card-body py-2">
data-bs-toggle="collapse" <div class="d-flex align-items-center gap-2">
data-bs-target="#question-{{ loop.index0 }}" {% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
aria-controls="question-{{ loop.index0 }}"> <span class="text-muted" style="cursor:grab" data-drag-handle>
{% set questionError = questionErrors[question.id.toString] ?? null %} <i class="bi bi-grip-vertical"></i>
<span </span>
class="badge rounded-pill me-2{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"{% if questionError %} data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span> {% endif %}
{{~ loop.index -}}. {{ question.question -}} <div class="flex-grow-1">
</button> {% set questionError = questionErrors[question.id.toString] ?? null %}
</h2> <span class="badge rounded-pill me-1{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"
<div id="question-{{ loop.index0 }}" {% if questionError %}data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span>
class="accordion-collapse collapse"> <strong><span data-question-number>{{ loop.index }}</span>. {{ question.question }}</strong>
<div class="accordion-body"> </div>
<ul> {% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
{%~ for answer in question.answers %} <button class="btn btn-sm btn-outline-secondary flex-shrink-0"
<li{% if answer.isRightAnswer %} class="text-decoration-underline"{% endif %}> data-action="click->bo--modal#open"
{{ answer.text }} data-src="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
{% if answer.candidates|length > 0 %} data-modal-title="{{ 'Edit question'|trans }}">
<small class="text-muted"> <i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
({{ answer.candidates|map(c => c.name)|join(', ') }}) </button>
</small> {% elseif quiz.isLocked %}
<button class="btn btn-sm btn-outline-secondary flex-shrink-0"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_quiz_question_view', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
data-modal-title="{{ 'Question details'|trans }}">
<i class="bi bi-eye"></i> {{ 'View'|trans }}
</button>
{% endif %} {% endif %}
</li>
{%~ else %}
{{ 'There are no answers for this question'|trans -}}
{%~ endfor %}
</ul>
</div> </div>
</div> </div>
</div> </div>
{% else %} {% else %}
{{ 'EMPTY'|trans }} {{ 'No questions have been added to this quiz yet.'|trans }}
{% endfor %} {% endfor %}
</div> </div>
<div class="modal fade" tabindex="-1"
data-bo--modal-target="modal"
aria-labelledby="questionEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<turbo-frame id="question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
</div>
{{ _self.confirm_modal( {{ _self.confirm_modal(
'clearQuizModal', 'clearQuizModal',
'clearModal', 'clearModal',
@@ -111,3 +171,7 @@
csrf_token('delete_quiz'), csrf_token('delete_quiz'),
) }} ) }}
</div> </div>
<div class="col-md-4 col-12">
{{ include('backoffice/help/quiz_overview.html.twig') }}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More