Compare commits

...

8 Commits

Author SHA1 Message Date
Marijn 5e7028c972 Add fullscreen toggle and return-to-page logout redirect (#200)
* feat: add fullscreen toggle and return-to-page logout redirect

Add a Stimulus-driven fullscreen button/keypress to the quiz and
elimination screens, and redirect admins back to the page they were on
after logging out (unless it was a backoffice or elimination page).

* docs: expand CLAUDE.md domain context and testing conventions

Document the WIDM quiz/elimination domain model in more depth, and add
guidance to prefer plain TestCase over WebTestCase/KernelTestCase and to
apply the Boy Scout Rule for small nearby cleanups.

* fix: drop f-keypress fullscreen shortcut

Candidate names can start with "f" while typing, so the keypress
conflicted with the enter-name form. Button-only toggle remains.
2026-07-09 21:30:06 +00:00
Marijn 0ee15e3cbb feat: add GDPR data export (download data) button (#198)
* feat: add GDPR data export (download data) button

Wires up the previously disabled "Download data" button on the settings
page. Downloads a zip with a profile.xlsx (account + owned seasons), and
per owned season a folder with each quiz's xlsx (questions, results,
eliminations tabs) and a candidates.xlsx (candidates + season info tabs).
Soft-deleted rows are included and flagged so the export reflects
everything the app still holds about the user.

* feat: include question bank in GDPR data export

Adds a question-bank.xlsx per season folder with Questions (bank
questions, answers, reusable/complete flags, labels, and which quizzes
they've been used in) and Labels tabs, since BankQuestion/BankAnswer
content was previously missing from the export.

* fix: hard-delete quiz/audit-log data when deleting an account

QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so
cascading their removal through Season -> Quiz/Candidate only set
deletedAt instead of physically deleting the row. Since Candidate and
Answer are hard-deleted via orphanRemoval, this broke their foreign keys
and rolled back the entire account deletion whenever a candidate had
actually participated in a quiz. Bulk DQL deletes now purge these rows
before the cascade runs.

Also purge BankQuestion audit-log rows (ext_log_entries), which store
the editor's username/email but aren't foreign-keyed to the entity they
log, so they were never cleaned up and would otherwise keep a deleted
user's email around indefinitely.

* style: make the download data button primary (blue)

* feat: add raw answers crosstab to quiz export

Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one
column per question, with the given answer text in each cell — the raw
data behind the aggregated Results tab.

* i18n: translate new settings page string to Dutch

* refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex

Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by
symfony/string's AsciiSlugger) and uses it everywhere user-controlled
text (season/quiz names, account email) ends up in a zip entry path or
a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9
survive; everything else, including unicode and path-traversal
sequences, is folded or stripped) rather than a denylist of "unsafe"
characters, and it's an officially maintained Symfony component already
present as a transitive dependency.

Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint
that built its Content-Disposition filename directly from an
unsanitized quiz name — the same class of risk the data export already
guarded against.

Naming note: sanitized names are now slugs (spaces become dashes,
unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend".

* fix: check ZipArchive open/close results and clean up temp files on failure

Addresses CodeRabbit findings on the GDPR export:
- ZipArchive::open() and close() can both return false without
  throwing; neither was checked, so a failure silently produced an
  empty or corrupt zip, and the temp zip path was never cleaned up on
  a mid-build exception.
- writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save()
  threw before the caller could track it for cleanup.

* fix: drop public link identifier from the candidates export sheet

The nameHash is a public quiz-access token, not something a data
export should hand out — remove it from candidates.xlsx.

* feat: add Quiz info tab covering dropouts, finalization, and disabled questions

An entity-by-entity audit of the export vs. delete flows found the
delete flow fully covered, but three Quiz/Question fields missing from
the export: dropouts, finalizedAt, and Question.enabled. Adds a new
"Quiz info" tab (first sheet) to each quiz's xlsx with this data,
without touching the shared fillQuestionsSheet() used by the existing
single-quiz template export/import feature.

Deliberately left out per user decision: the BankQuestion audit log
(would leak other owners' emails, consistent with hiding co-owner
identities elsewhere in this export) and a few low-value timestamp
fields already covered by existing Started/time-taken columns.

* feat: require a confirmed email before exporting data

Antispam measure: both the full data export (SettingsController::downloadData)
and the single-quiz export (BackofficeController::exportQuiz) now redirect
with a flash warning instead of exporting when the account's email isn't
verified yet. Adds a matching hint on the settings page next to the
download button.
2026-07-09 20:28:10 +00:00
Marijn 352e34a428 feat: add candidate rename and delete (#194)
Adds per-candidate rename and delete actions to the candidates tab,
guarded by confirmation modals since deletion also discards the
candidate's given answers.

Closes #18
2026-07-09 19:00:47 +02:00
Marijn b1a959fdc6 feat: show active/ready badges on quiz list (#193)
* feat: show active/ready badges on quiz list

Adds an "Active" badge for the season's currently selected quiz and
renames the "Finalized" badge to "Ready" for finalized quizzes, so
both states are visible at a glance.

* fix: hide Ready badge on the active quiz, translate to Voorbereid

An active quiz is finalized by definition, so showing both badges was
redundant.

* fix: align quiz status badges to the left of the row

Previously the badge trailed the quiz name, so its position shifted
with the name's length. Placing it first keeps it at a consistent
left edge across all rows.

* fix: right-align quiz status badges

Puts the badge at the end of the row via ms-auto instead of leading
the quiz name.
2026-07-09 18:31:30 +02:00
Marijn 27d3d64154 feat: add button to regenerate season code (#192)
Adds a "regenerate season code" action to the season settings page,
allowing owners to invalidate the current code (e.g. if it was shared
by mistake) and get a fresh one for candidates to join with.

Closes #14
2026-07-09 18:30:46 +02:00
dependabot[bot] e7586c2d6b build(deps): bump guzzlehttp/psr7 from 2.12.3 to 2.12.4 (#196)
Bumps [guzzlehttp/psr7](https://github.com/guzzle/psr7) from 2.12.3 to 2.12.4.
- [Release notes](https://github.com/guzzle/psr7/releases)
- [Changelog](https://github.com/guzzle/psr7/blob/2.12/CHANGELOG.md)
- [Commits](https://github.com/guzzle/psr7/compare/2.12.3...2.12.4)

---
updated-dependencies:
- dependency-name: guzzlehttp/psr7
  dependency-version: 2.12.4
  dependency-type: indirect
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 18:30:08 +02:00
dependabot[bot] a49d32e8ff build(deps): bump phpstan/phpdoc-parser from 2.3.2 to 2.3.3 (#197)
Bumps [phpstan/phpdoc-parser](https://github.com/phpstan/phpdoc-parser) from 2.3.2 to 2.3.3.
- [Release notes](https://github.com/phpstan/phpdoc-parser/releases)
- [Commits](https://github.com/phpstan/phpdoc-parser/compare/2.3.2...2.3.3)

---
updated-dependencies:
- dependency-name: phpstan/phpdoc-parser
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 18:29:45 +02:00
dependabot[bot] 4547a43199 build(deps-dev): bump phpunit/phpunit in the dev-dependencies group (#195)
Bumps the dev-dependencies group with 1 update: [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit).


Updates `phpunit/phpunit` from 13.2.3 to 13.2.4
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/13.2.4/ChangeLog-13.2.md)
- [Commits](https://github.com/sebastianbergmann/phpunit/compare/13.2.3...13.2.4)

---
updated-dependencies:
- dependency-name: phpunit/phpunit
  dependency-version: 13.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 23:55:07 +00:00
30 changed files with 1889 additions and 76 deletions
+110 -10
View File
@@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with: **Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) —
a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode,
participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is
eliminated. This app replicates that quiz format with:
- Test creation with variable question counts - Test creation with variable question counts
- Season management with active test controls - Season management with active test controls
- Candidate answer tracking with automatic timing - Candidate answer tracking with automatic timing
@@ -12,6 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Backoffice management for quiz administration and statistics - Backoffice management for quiz administration and statistics
Tech Stack: Tech Stack:
- **Framework**: Symfony 8.1 - **Framework**: Symfony 8.1
- **PHP**: 8.5+ - **PHP**: 8.5+
- **Database**: PostgreSQL 16 - **Database**: PostgreSQL 16
@@ -122,49 +127,140 @@ tests/
- **Elimination**: Records red/green screens and forced results with joker adjustments. - **Elimination**: Records red/green screens and forced results with joker adjustments.
- **User**: Administrative accounts for managing the system. - **User**: Administrative accounts for managing the system.
## Domain Context: "De Test" (Wie is de Mol)
**Wie is de Mol?** (WIDM) is a Dutch reality competition: a group of contestants ("kandidaten") travels together while
one of them, "de Mol", secretly sabotages assignments. Each episode ends with the fixed line: *"Tijd voor de test.
Twintig vragen over de identiteit en het doen en laten van de Mol. Degene die het minst weet, ligt uit het spel. Behalve
de Mol. Die hoeft nooit naar huis."* ("Time for the test. Twenty questions about the identity and the actions of the
Mol. Whoever knows the least is out of the game. Except the Mol — they never have to go home.") The contestant with the
worst score is eliminated ("afvallen"); the Mol is immune regardless of score, since they already know the answers. This
app is a generic engine for running that quiz format for private/fan seasons, not just modeling the TV show
incidentally — the entity model below exists specifically to reproduce WIDM's test mechanics.
### What a test's 20 questions actually are
Per the intro line, questions fall into two factual categories — never opinion ("who would you vote off") — plus a
third recurring format used on the show:
1. **Identity of the Mol**: guessing which contestant is the Mol.
2. **The Mol's actions**: what the Mol did or where the Mol was during a specific assignment/moment.
3. **Candidate self-answered questions**: earlier, every contestant privately answered a question about themselves
(an interview-style question); the test then asks other contestants to guess what a *specific* candidate answered
about themselves. This tests how well contestants know each other, not just Mol-tracking.
### Why answers can be bound to candidates
All three categories above can have contestants themselves as the answer options rather than free text: "who is the
Mol" and "who did X" both need contestant names as options, and "what did candidate Y answer" needs Y's own submitted
answer among the options. In the domain model this is `Answer::$candidates` (a `ManyToMany` to `Candidate`, on both
sides): an answer option can *be* another contestant, not just text.
Because the relationship is many-to-many on the answer side too, a single answer option can cover **more than one
candidate at once** — e.g. "Anna en Bram" as one option for "who missed the assignment together", or an option
representing everyone who gave a particular self-answer in category 3 above. So a candidate-bound answer isn't always
one candidate, it can be a group; treat `Answer::$candidates` as "the set of contestants this option represents", not
as a single foreign key.
Combined with `GivenAnswer::$candidate` (who answered), every given answer on a candidate-bound question is a directed
relationship from the answering candidate to *every* candidate covered by the chosen option — a one-to-many edge when
the option is a group, not just candidate A pointed at candidate B. This is the mechanic behind any "who's suspected of
what" or sociogram-style statistic — it only applies to candidate-bound questions, plain trivia questions have no such
relationship. `Quiz::getQuestionErrors()` already relies on this distinction to validate that every active candidate is
covered exactly once per candidate-bound question (a candidate appearing across multiple group-options on the same
question counts as covered more than once).
### Elimination mechanics
- **Red/green screens**: at the end of a test, contestants are shown red or green screens one at a time to build tension
before the elimination is revealed. `Elimination::$data` stores the colour shown per candidate (
`SCREEN_RED/SCREEN_GREEN` via `getScreenColour()`), independent of the actual quiz score.
- **Jokers / corrections**: contestants can hold a "joker" (an advantage, e.g. an extra correct answer) that adjusts
their effective score without changing what they actually answered. This is `QuizCandidate::$corrections` — a float
added to the raw score, kept separate from `GivenAnswer` so the audit trail of what was actually answered stays
untouched.
- **Dropouts**: `Quiz::$dropouts` controls how many contestants can be eliminated in a single test (normally 1, but some
episodes eliminate more).
- **Finalization/locking**: `Quiz::$isFinalized` and `$isLocked` gate when a quiz's questions/answers can still be
edited — a quiz becomes immutable once a candidate has started it or an admin explicitly finalizes it. Treat this as
the natural point where computed results (scores, statistics) can be cached indefinitely, since nothing that feeds
them can change afterward.
### Terminology map (Dutch UI ↔ domain code)
| UI/domain term (Dutch) | Code |
|------------------------------|-------------------------------|
| Test | `Quiz` |
| Vraag | `Question` |
| Antwoord | `Answer` |
| Kandidaat | `Candidate` |
| Ingevuld antwoord | `GivenAnswer` |
| Afvallen / rood-groen scherm | `Elimination` |
| Joker / correctie | `QuizCandidate::$corrections` |
## Architecture Notes ## Architecture Notes
### Routing ### Routing
- Routes are **attribute-based** (PHP 8 attributes in controller methods) - Routes are **attribute-based** (PHP 8 attributes in controller methods)
- Configured in `config/routes/attributes.yaml` for automatic discovery - Configured in `config/routes/attributes.yaml` for automatic discovery
- Main entry point: `config/routes.yaml` - Main entry point: `config/routes.yaml`
### Service Container & Dependency Injection ### Service Container & Dependency Injection
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\` - Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
- Exclusions: Entity, DependencyInjection, Kernel classes - Exclusions: Entity, DependencyInjection, Kernel classes
- Autowiring and autoconfiguration enabled by default - Autowiring and autoconfiguration enabled by default
- Service definitions in `config/services.yaml` - Service definitions in `config/services.yaml`
### Database & Migrations ### Database & Migrations
- PostgreSQL-based with Doctrine ORM - PostgreSQL-based with Doctrine ORM
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration` - Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate
with `bin/console make:migration`
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`) - Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
- Test database configured separately via `.env.test` - Test database configured separately via `.env.test`
### Testing Infrastructure ### Testing Infrastructure
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback - **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector) - Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test
kernel/container (used by Rector)
- Symfony test utilities (BrowserKit, CSS selectors) available - Symfony test utilities (BrowserKit, CSS selectors) available
- Coverage excluded from: `src/DataFixtures/` - Coverage excluded from: `src/DataFixtures/`
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml) - Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
### Testing Conventions (TDD) ### Testing Conventions (TDD)
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes.
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure
before touching the production code. Fix the code until the test passes.
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it. - Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists in a template). Tests cover behavior: routing, forms, persistence, authorization. - Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state. in a template). Tests cover behavior: routing, forms, persistence, authorization.
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token,
POST form data, assert redirect, clear entity manager, assert DB state.
- **Prefer `TestCase` over `WebTestCase`/`KernelTestCase`.** Reach for the full kernel/DB boot only when the test
genuinely needs routing, persistence, or the container — pure logic (services, listeners, helpers) should be tested
with plain PHPUnit `TestCase` and mocked dependencies; it's faster and more isolated.
- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same
commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later —
but don't let this balloon into an unrelated refactor.
### Code Style & Standards ### Code Style & Standards
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled - **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
- Strict types declaration required - Strict types declaration required
- Trailing commas in multiline structures - Trailing commas in multiline structures
- No else-only blocks - No else-only blocks
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit) - **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine,
Symfony, PHPUnit)
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony - **PHPStan**: Level 8 with extensions for Doctrine and Symfony
- **Twig-CS-Fixer**: Template style enforcement - **Twig-CS-Fixer**: Template style enforcement
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead - **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
they throw exceptions instead
### Environment Configuration ### Environment Configuration
- `.env` - Local development defaults (uncommitted in .env.local) - `.env` - Local development defaults (uncommitted in .env.local)
- `.env.dev` - Development overrides - `.env.dev` - Development overrides
- `.env.test` - Test environment configuration - `.env.test` - Test environment configuration
@@ -175,6 +271,7 @@ tests/
- `MAILER_SENDER` - From address for emails - `MAILER_SENDER` - From address for emails
### Frontend Build ### Frontend Build
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php` - Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation - **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build` - Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
@@ -207,7 +304,8 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
## Important Files & Conventions ## Important Files & Conventions
- **Kernel**: `src/Kernel.php` - Symfony kernel class - **Kernel**: `src/Kernel.php` - Symfony kernel class
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers - **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`,
`CANDIDATE_HASH_REGEX`) and flash helpers
- **Flash Messages**: Use `FlashType` enum instead of string literals - **Flash Messages**: Use `FlashType` enum instead of string literals
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files - **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types - **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
@@ -226,6 +324,7 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
## Composer Scripts ## Composer Scripts
Auto-executed scripts on install/update: Auto-executed scripts on install/update:
- `cache:clear` - Symfony cache clear - `cache:clear` - Symfony cache clear
- `assets:install` - Copy public assets - `assets:install` - Copy public assets
- `importmap:install` - JS import map setup - `importmap:install` - JS import map setup
@@ -244,4 +343,5 @@ When writing Dutch help content in `templates/backoffice/help/nl/`:
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php` - The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
- Quiz timing logic starts on candidate start click and stops on final answer selection - Quiz timing logic starts on candidate start click and stops on final answer selection
- Background music feature noted but not yet implemented (requirements only) - Background music feature noted but not yet implemented (requirements only)
- Statistics functionality is marked TBD in README - Statistics module (per-quiz statistics page, candidate accusation matrix, caching) is planned per GitHub issue #199
see "Domain Context" above for why candidate-bound answers matter to it
@@ -0,0 +1,40 @@
import {Controller} from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect() {
this.onFullscreenChange = this.onFullscreenChange.bind(this);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) {
this.request();
}
}
disconnect() {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
}
toggle() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request() {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange() {
sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0');
this.syncState();
}
syncState() {
document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement));
}
}
+26
View File
@@ -94,6 +94,32 @@ input.btn-check:checked + label.answer-btn {
gap: 0.5rem; gap: 0.5rem;
} }
.fullscreen-btn {
position: fixed;
bottom: 0.75rem;
left: 0.75rem;
z-index: 1040;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.35);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
&:hover,
&:focus {
color: rgba(255, 255, 255, 0.8);
outline: none;
}
}
html.is-fullscreen .fullscreen-btn {
display: none;
}
.elimination-screen { .elimination-screen {
position: fixed; position: fixed;
top: 0; top: 0;
+1
View File
@@ -35,6 +35,7 @@
"symfony/security-bundle": "8.1.*", "symfony/security-bundle": "8.1.*",
"symfony/security-csrf": "8.1.*", "symfony/security-csrf": "8.1.*",
"symfony/serializer": "8.1.*", "symfony/serializer": "8.1.*",
"symfony/string": "8.1.*",
"symfony/translation": "8.1.*", "symfony/translation": "8.1.*",
"symfony/twig-bundle": "8.1.*", "symfony/twig-bundle": "8.1.*",
"symfony/uid": "8.1.*", "symfony/uid": "8.1.*",
Generated
+20 -20
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": "010a4456ebc1a8ebaf73c6db051d3d09", "content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5",
"packages": [ "packages": [
{ {
"name": "composer/pcre", "name": "composer/pcre",
@@ -1475,16 +1475,16 @@
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.12.3", "version": "2.12.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/psr7.git", "url": "https://github.com/guzzle/psr7.git",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1574,7 +1574,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/psr7/issues", "issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.12.3" "source": "https://github.com/guzzle/psr7/tree/2.12.4"
}, },
"funding": [ "funding": [
{ {
@@ -1590,7 +1590,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-23T15:21:08+00:00" "time": "2026-07-08T15:56:20+00:00"
}, },
{ {
"name": "jean85/pretty-package-versions", "name": "jean85/pretty-package-versions",
@@ -2253,16 +2253,16 @@
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.2", "version": "2.3.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2294,9 +2294,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
}, },
"time": "2026-01-25T14:56:51+00:00" "time": "2026-07-08T07:01:06+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",
@@ -10452,16 +10452,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "13.2.3", "version": "13.2.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54" "reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8f5180f4627fc1978be2f61d8d9979dbe37e0c10",
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54", "reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10475,7 +10475,7 @@
"phar-io/manifest": "^2.0.4", "phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1", "phar-io/version": "^3.2.1",
"php": ">=8.4.1", "php": ">=8.4.1",
"phpunit/php-code-coverage": "^14.2.2", "phpunit/php-code-coverage": "^14.2.3",
"phpunit/php-file-iterator": "^7.0.0", "phpunit/php-file-iterator": "^7.0.0",
"phpunit/php-invoker": "^7.0.0", "phpunit/php-invoker": "^7.0.0",
"phpunit/php-text-template": "^6.0.0", "phpunit/php-text-template": "^6.0.0",
@@ -10532,7 +10532,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.3" "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.4"
}, },
"funding": [ "funding": [
{ {
@@ -10540,7 +10540,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-07-06T14:55:56+00:00" "time": "2026-07-08T08:36:51+00:00"
}, },
{ {
"name": "react/cache", "name": "react/cache",
@@ -14,10 +14,13 @@ use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController; use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType;
use Tvdt\Form\CreateSeasonFormType; use Tvdt\Form\CreateSeasonFormType;
use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Repository\SeasonRepository; use Tvdt\Repository\SeasonRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
@@ -31,6 +34,7 @@ final class BackofficeController extends AbstractController
private readonly Security $security, private readonly Security $security,
private readonly QuizSpreadsheetService $excel, private readonly QuizSpreadsheetService $excel,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly TranslatorInterface $translator,
) {} ) {}
#[Route('/backoffice/', name: 'tvdt_backoffice_index')] #[Route('/backoffice/', name: 'tvdt_backoffice_index')]
@@ -83,11 +87,17 @@ final class BackofficeController extends AbstractController
requirements: ['quiz' => Requirement::UUID], requirements: ['quiz' => Requirement::UUID],
methods: ['GET'], methods: ['GET'],
)] )]
public function exportQuiz(Quiz $quiz): StreamedResponse public function exportQuiz(Quiz $quiz): Response
{ {
if (!$this->authenticatedUser->isVerified) {
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before exporting a quiz.'));
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
}
$response = new StreamedResponse($this->excel->quizToXlsx($quiz)); $response = new StreamedResponse($this->excel->quizToXlsx($quiz));
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $quiz->name.'.xlsx')); $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, FilenameSanitizer::sanitize($quiz->name).'.xlsx'));
return $response; return $response;
} }
@@ -10,10 +10,13 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
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;
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\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
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\Length;
use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotBlank;
@@ -26,6 +29,7 @@ use Tvdt\Enum\FlashType;
use Tvdt\Form\AddCandidatesFormType; use Tvdt\Form\AddCandidatesFormType;
use Tvdt\Form\SettingsForm; use Tvdt\Form\SettingsForm;
use Tvdt\Form\UploadQuizFormType; use Tvdt\Form\UploadQuizFormType;
use Tvdt\Repository\CandidateRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
@@ -37,6 +41,7 @@ class SeasonController extends AbstractController
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly QuizSpreadsheetService $quizSpreadsheet, private readonly QuizSpreadsheetService $quizSpreadsheet,
private readonly CandidateRepository $candidateRepository,
) {} ) {}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -97,6 +102,24 @@ class SeasonController extends AbstractController
]); ]);
} }
#[IsCsrfTokenValid('regenerate_season_code')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/settings/regenerate-code',
name: 'tvdt_backoffice_season_regenerate_code',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
methods: ['POST'],
)]
public function regenerateSeasonCode(Season $season): RedirectResponse
{
$season->generateSeasonCode();
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Season code regenerated'));
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route( #[Route(
'/backoffice/season/{seasonCode:season}/add-candidate', '/backoffice/season/{seasonCode:season}/add-candidate',
@@ -123,6 +146,56 @@ class SeasonController extends AbstractController
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]); return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
} }
#[IsCsrfTokenValid('rename_candidate')]
#[IsGranted(SeasonVoter::EDIT, subject: 'candidate')]
#[Route(
'/backoffice/season/{seasonCode:season}/candidate/{candidate}/rename',
name: 'tvdt_backoffice_candidate_rename',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function renameCandidate(Season $season, Candidate $candidate, Request $request): RedirectResponse
{
$name = mb_trim($request->request->getString('name'));
if ('' === $name || mb_strlen($name) > 16) {
$this->addFlash(FlashType::Danger, $this->translator->trans('The candidate name must be between 1 and 16 characters'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
$candidate->name = $name;
try {
$this->em->flush();
} catch (UniqueConstraintViolationException) {
$this->addFlash(FlashType::Danger, $this->translator->trans('A candidate with this name already exists in this season'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate renamed'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('delete_candidate')]
#[IsGranted(SeasonVoter::DELETE, subject: 'candidate')]
#[Route(
'/backoffice/season/{seasonCode:season}/candidate/{candidate}/delete',
name: 'tvdt_backoffice_candidate_delete',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function deleteCandidate(Season $season, Candidate $candidate): RedirectResponse
{
$this->candidateRepository->deleteCandidate($candidate);
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate deleted'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route( #[Route(
'/backoffice/season/{seasonCode:season}/add-quiz', '/backoffice/season/{seasonCode:season}/add-quiz',
@@ -6,9 +6,12 @@ namespace Tvdt\Controller\Backoffice;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Safe\DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
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;
@@ -21,8 +24,10 @@ use Tvdt\Entity\User;
use Tvdt\Enum\FlashType; use Tvdt\Enum\FlashType;
use Tvdt\Form\ChangeEmailFormType; use Tvdt\Form\ChangeEmailFormType;
use Tvdt\Form\ChangeUserPasswordFormType; use Tvdt\Form\ChangeUserPasswordFormType;
use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Repository\UserRepository; use Tvdt\Repository\UserRepository;
use Tvdt\Security\EmailVerifier; use Tvdt\Security\EmailVerifier;
use Tvdt\Service\DataExportService;
final class SettingsController extends AbstractController final class SettingsController extends AbstractController
{ {
@@ -33,6 +38,7 @@ final class SettingsController extends AbstractController
private readonly EmailVerifier $emailVerifier, private readonly EmailVerifier $emailVerifier,
private readonly Security $security, private readonly Security $security,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly DataExportService $dataExportService,
) {} ) {}
#[Route('/backoffice/settings', name: 'tvdt_backoffice_settings', methods: ['GET'])] #[Route('/backoffice/settings', name: 'tvdt_backoffice_settings', methods: ['GET'])]
@@ -148,6 +154,34 @@ final class SettingsController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_settings'); return $this->redirectToRoute('tvdt_backoffice_settings');
} }
#[Route('/backoffice/settings/download-data', name: 'tvdt_backoffice_settings_download_data', methods: ['GET'])]
public function downloadData(): Response
{
if (!$this->authenticatedUser->isVerified) {
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before downloading your data.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
$zipPath = $this->dataExportService->exportForUser($this->authenticatedUser);
$filename = \sprintf(
'tijd-voor-de-test-data-%s-%s.zip',
FilenameSanitizer::sanitize($this->authenticatedUser->email),
new DateTimeImmutable()->format('Y-m-d_H-i-s'),
);
$response = new BinaryFileResponse($zipPath);
$response->deleteFileAfterSend(true);
$response->headers->set('Content-Type', 'application/zip');
$response->headers->set(
'Content-Disposition',
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename),
);
return $response;
}
#[IsCsrfTokenValid('delete_account')] #[IsCsrfTokenValid('delete_account')]
#[Route('/backoffice/settings/delete', name: 'tvdt_backoffice_settings_delete', methods: ['POST'])] #[Route('/backoffice/settings/delete', name: 'tvdt_backoffice_settings_delete', methods: ['POST'])]
public function deleteAccount(Request $request): Response public function deleteAccount(Request $request): Response
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Tvdt\Helpers;
use Symfony\Component\String\Slugger\AsciiSlugger;
class FilenameSanitizer
{
/** Slugs user-supplied text (e.g. a season/quiz name) into a string safe to use as a zip entry path segment or a downloaded filename. */
public static function sanitize(string $value): string
{
$slug = new AsciiSlugger()->slug($value)->toString();
return '' === $slug ? 'unnamed' : $slug;
}
}
+6
View File
@@ -19,6 +19,12 @@ class CandidateRepository extends ServiceEntityRepository
parent::__construct($registry, Candidate::class); parent::__construct($registry, Candidate::class);
} }
public function deleteCandidate(Candidate $candidate): void
{
$this->getEntityManager()->remove($candidate);
$this->getEntityManager()->flush();
}
public function getCandidateByHash(Season $season, string $hash): ?Candidate public function getCandidateByHash(Season $season, string $hash): ?Candidate
{ {
try { try {
+63
View File
@@ -5,9 +5,15 @@ declare(strict_types=1);
namespace Tvdt\Repository; namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
/** @extends ServiceEntityRepository<User> */ /** @extends ServiceEntityRepository<User> */
@@ -44,8 +50,11 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$em->wrapInTransaction(function () use ($em, $user): void { $em->wrapInTransaction(function () use ($em, $user): void {
$this->invalidateResetPasswordRequests($user); $this->invalidateResetPasswordRequests($user);
$bankQuestionIds = [];
foreach ($user->seasons->toArray() as $season) { foreach ($user->seasons->toArray() as $season) {
if (1 === $season->owners->count()) { if (1 === $season->owners->count()) {
$this->purgeSoftDeletableData($em, $season);
array_push($bankQuestionIds, ...$this->bankQuestionIds($season));
$em->remove($season); $em->remove($season);
continue; continue;
@@ -56,9 +65,63 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$em->remove($user); $em->remove($user);
$em->flush(); $em->flush();
// Gedmo\Loggable writes its own "removed" log entry as part of the flush above, so the
// audit-log purge must happen after — purging first would just leave that final row behind.
$this->purgeBankQuestionAuditLog($em, $bankQuestionIds);
}); });
} }
/**
* QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their
* removal through the season/quiz/candidate relations only sets deletedAt — it never removes
* the row. That leaves personal data behind indefinitely and, since Candidate/Answer are hard
* deleted via orphanRemoval, it also breaks their foreign keys and rolls back the whole
* deletion. Bulk DQL deletes bypass the Gedmo listener and physically remove these rows first.
*/
private function purgeSoftDeletableData(EntityManagerInterface $em, Season $season): void
{
foreach ([QuizCandidate::class, GivenAnswer::class, Elimination::class] as $class) {
$em->createQuery(<<<DQL
delete from {$class} e
where e.quiz in (select q from Tvdt\Entity\Quiz q where q.season = :season)
DQL)
->setParameter('season', $season)
->execute();
}
}
/** @return list<string> */
private function bankQuestionIds(Season $season): array
{
return array_values(array_map(
static fn (BankQuestion $bankQuestion): string => $bankQuestion->id->toString(),
$season->bankQuestions->toArray(),
));
}
/**
* Gedmo\Loggable audit rows (ext_log_entries) aren't foreign-keyed to the entity they log —
* object_id is a plain string — so removing a BankQuestion never cleans up its history, and
* the editor's username/email would otherwise remain in those rows forever.
*
* @param list<string> $bankQuestionIds
*/
private function purgeBankQuestionAuditLog(EntityManagerInterface $em, array $bankQuestionIds): void
{
if ([] === $bankQuestionIds) {
return;
}
$em->createQuery(<<<'DQL'
delete from Tvdt\Entity\LogEntry l
where l.objectClass = :class and l.objectId in (:ids)
DQL)
->setParameter('class', BankQuestion::class)
->setParameter('ids', $bankQuestionIds)
->execute();
}
public function makeAdmin(string $email): void public function makeAdmin(string $email): void
{ {
$user = $this->findOneBy(['email' => $email]); $user = $this->findOneBy(['email' => $email]);
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tvdt\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
final readonly class LogoutRedirectListener implements EventSubscriberInterface
{
private const array BLOCKED_TARGET_PREFIXES = ['/backoffice', '/elimination'];
public function __construct(private UrlGeneratorInterface $urlGenerator) {}
public function onLogout(LogoutEvent $event): void
{
$target = $event->getRequest()->query->get('target');
if (\is_string($target) && $this->isAllowedTarget($target)) {
$event->setResponse(new RedirectResponse($target));
return;
}
$event->setResponse(new RedirectResponse($this->urlGenerator->generate('tvdt_quiz_select_season')));
}
public static function getSubscribedEvents(): array
{
// Must run before Symfony's DefaultLogoutListener (priority 64), which only
// sets a response if none is set yet.
return [
LogoutEvent::class => ['onLogout', 128],
];
}
private function isAllowedTarget(string $target): bool
{
if (!str_starts_with($target, '/') || str_starts_with($target, '//')) {
return false;
}
return !array_any(self::BLOCKED_TARGET_PREFIXES, static fn (string $prefix): bool => str_starts_with($target, $prefix));
}
}
+444
View File
@@ -0,0 +1,444 @@
<?php
declare(strict_types=1);
namespace Tvdt\Service;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer;
use Safe\Exceptions\FilesystemException;
use Tvdt\Dto\Result;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Repository\QuizRepository;
use function Safe\tempnam;
use function Safe\unlink;
/** Builds a GDPR data-portability export (a zip of xlsx files) for everything owned by a single user. */
class DataExportService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly QuizSpreadsheetService $quizSpreadsheetService,
private readonly QuizRepository $quizRepository,
) {}
/** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */
public function exportForUser(User $user): string
{
$filter = $this->entityManager->getFilters();
$filter->disable('softdeleteable');
try {
return $this->buildZip($user);
} finally {
$filter->enable('softdeleteable');
}
}
private function buildZip(User $user): string
{
$zipPath = tempnam(sys_get_temp_dir(), 'tvdt_export_');
$tempXlsxFiles = [];
$zip = new \ZipArchive();
if (true !== $zip->open($zipPath, \ZipArchive::OVERWRITE)) {
unlink($zipPath);
throw new \RuntimeException('Could not create the export zip archive.');
}
try {
$profilePath = $this->writeToTempFile($this->buildProfileWorkbook($user));
$tempXlsxFiles[] = $profilePath;
$zip->addFile($profilePath, 'profile.xlsx');
foreach ($user->seasons as $season) {
$folder = FilenameSanitizer::sanitize($season->seasonCode.'-'.$season->name).'/';
foreach ($season->quizzes as $quiz) {
$quizPath = $this->writeToTempFile($this->buildQuizWorkbook($quiz));
$tempXlsxFiles[] = $quizPath;
$zip->addFile($quizPath, $folder.FilenameSanitizer::sanitize($quiz->name).'.xlsx');
}
$candidatesPath = $this->writeToTempFile($this->buildCandidatesWorkbook($season));
$tempXlsxFiles[] = $candidatesPath;
$zip->addFile($candidatesPath, $folder.'candidates.xlsx');
$questionBankPath = $this->writeToTempFile($this->buildQuestionBankWorkbook($season));
$tempXlsxFiles[] = $questionBankPath;
$zip->addFile($questionBankPath, $folder.'question-bank.xlsx');
}
if (!$zip->close()) {
throw new \RuntimeException('Could not finalize the export zip archive.');
}
} catch (\Throwable $throwable) {
unlink($zipPath);
throw $throwable;
} finally {
foreach ($tempXlsxFiles as $tempXlsxFile) {
unlink($tempXlsxFile);
}
}
return $zipPath;
}
private function buildProfileWorkbook(User $user): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$account = $spreadsheet->getActiveSheet();
$account->setTitle('Account');
$account->getStyle('A:A')->getFont()->setBold(true);
$account->fromArray([
['Email', $user->email],
['Roles', implode(', ', $user->getRoles())],
['Email verified', $user->isVerified ? 'Yes' : 'No'],
['Account ID', $user->id->toString()],
], null, 'A1');
$account->getColumnDimension('A')->setAutoSize(true);
$account->getColumnDimension('B')->setAutoSize(true);
$seasons = $spreadsheet->createSheet();
$seasons->setTitle('Seasons');
$seasons->fromArray(['Season', 'Season code', 'Quizzes', 'Candidates', 'Shared with other owners'], null, 'A1');
$seasons->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($user->seasons as $season) {
$seasons->fromArray([
$season->name,
$season->seasonCode,
$season->quizzes->count(),
$season->candidates->count(),
$season->owners->count() > 1 ? 'Yes' : 'No',
], null, 'A'.$row);
++$row;
}
foreach (['A', 'B', 'C', 'D', 'E'] as $column) {
$seasons->getColumnDimension($column)->setAutoSize(true);
}
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function buildQuizWorkbook(Quiz $quiz): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$info = $spreadsheet->getActiveSheet();
$info->setTitle('Quiz info');
$this->fillQuizInfoSheet($info, $quiz);
$questions = $spreadsheet->createSheet();
$questions->setTitle('Questions');
$this->quizSpreadsheetService->fillQuestionsSheet($questions, $quiz);
$rawAnswers = $spreadsheet->createSheet();
$rawAnswers->setTitle('Raw answers');
$this->fillRawAnswersSheet($rawAnswers, $quiz);
$results = $spreadsheet->createSheet();
$results->setTitle('Results');
$this->fillResultsSheet($results, $quiz);
$eliminations = $spreadsheet->createSheet();
$eliminations->setTitle('Eliminations');
$this->fillEliminationsSheet($eliminations, $quiz);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function fillQuizInfoSheet(Worksheet $sheet, Quiz $quiz): void
{
$disabledQuestions = $quiz->questions
->filter(static fn (Question $question): bool => !$question->enabled)
->map(static fn (Question $question): string => $question->question)
->toArray();
$sheet->getStyle('A:A')->getFont()->setBold(true);
$sheet->fromArray([
['Quiz name', $quiz->name],
['Number of dropouts', $quiz->dropouts],
['Finalized', $quiz->isFinalized ? 'Yes' : 'No'],
['Finalized at', $quiz->finalizedAt?->format(\DateTimeInterface::ATOM) ?? ''],
['Disabled questions', implode(', ', $disabledQuestions)],
], null, 'A1');
$sheet->getColumnDimension('A')->setAutoSize(true);
$sheet->getColumnDimension('B')->setAutoSize(true);
}
private function fillResultsSheet(Worksheet $sheet, Quiz $quiz): void
{
$sheet->fromArray(['Candidate', 'Correct answers', 'Corrections', 'Penalty (s)', 'Score', 'Time', 'Started', 'Active', 'Deleted'], null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
/** @var array<string, Result> $scoresByCandidateId */
$scoresByCandidateId = [];
foreach ($this->quizRepository->getScores($quiz) as $result) {
$scoresByCandidateId[$result->id->toString()] = $result;
}
$row = 2;
foreach ($quiz->candidateData as $quizCandidate) {
$candidate = $quizCandidate->candidate;
$result = $scoresByCandidateId[$candidate->id->toString()] ?? null;
$sheet->fromArray([
$candidate->name,
$result?->correct,
$result?->corrections,
$result?->penaltySeconds,
$result?->score,
$result instanceof Result ? $result->time->format('%i:%S') : null,
$quizCandidate->started?->format(\DateTimeInterface::ATOM),
$quizCandidate->active ? 'Yes' : 'No',
$quizCandidate->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
], null, 'A'.$row);
++$row;
}
foreach (range('A', 'I') as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */
private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void
{
/** @var list<Question> $questions */
$questions = $quiz->questions->toArray();
$header = ['Candidate'];
foreach ($questions as $question) {
$header[] = $question->question;
}
$sheet->fromArray($header, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$sheet->getStyle('1:1')->getAlignment()->setWrapText(true);
/** @var array<string, array<string, string>> $answersByCandidateAndQuestion */
$answersByCandidateAndQuestion = [];
foreach ($questions as $question) {
foreach ($question->answers as $answer) {
foreach ($answer->givenAnswers as $givenAnswer) {
$candidateId = $givenAnswer->candidate->id->toString();
$answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text;
}
}
}
$row = 2;
foreach ($quiz->candidateData as $quizCandidate) {
$candidate = $quizCandidate->candidate;
$line = [$candidate->name];
foreach ($questions as $question) {
$line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? '';
}
$sheet->fromArray($line, null, 'A'.$row);
++$row;
}
$lastColumnIndex = 1 + \count($questions);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
$sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
}
}
private function fillEliminationsSheet(Worksheet $sheet, Quiz $quiz): void
{
/** @var list<Candidate> $candidates */
$candidates = $quiz->season->candidates->toArray();
$header = ['Prepared at', 'Deleted'];
foreach ($candidates as $candidate) {
$header[] = $candidate->name;
}
$sheet->fromArray($header, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($quiz->eliminations as $elimination) {
$line = [
$elimination->getCreatedAt()?->format(\DateTimeInterface::ATOM) ?? '',
$elimination->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
];
foreach ($candidates as $candidate) {
$line[] = $elimination->getScreenColour($candidate->name) ?? '';
}
$sheet->fromArray($line, null, 'A'.$row);
++$row;
}
foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
private function buildCandidatesWorkbook(Season $season): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$candidatesSheet = $spreadsheet->getActiveSheet();
$candidatesSheet->setTitle('Candidates');
$candidatesSheet->fromArray(['Name'], null, 'A1');
$candidatesSheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($season->candidates as $candidate) {
$candidatesSheet->fromArray([$candidate->name], null, 'A'.$row);
++$row;
}
$candidatesSheet->getColumnDimension('A')->setAutoSize(true);
$infoSheet = $spreadsheet->createSheet();
$infoSheet->setTitle('Season info');
$infoSheet->getStyle('A:A')->getFont()->setBold(true);
$infoSheet->fromArray([
['Season name', $season->name],
['Season code', $season->seasonCode],
['Number of quizzes', $season->quizzes->count()],
['Number of candidates', $season->candidates->count()],
['Active quiz', $season->activeQuiz instanceof Quiz ? $season->activeQuiz->name : ''],
['Show numbers', $season->settings?->showNumbers ? 'Yes' : 'No'],
['Confirm answers', $season->settings?->confirmAnswers ? 'Yes' : 'No'],
['Shared with other owners', $season->owners->count() > 1 ? 'Yes' : 'No'],
], null, 'A1');
$infoSheet->getColumnDimension('A')->setAutoSize(true);
$infoSheet->getColumnDimension('B')->setAutoSize(true);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function buildQuestionBankWorkbook(Season $season): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$questions = $spreadsheet->getActiveSheet();
$questions->setTitle('Questions');
$this->fillBankQuestionsSheet($questions, $season);
$labels = $spreadsheet->createSheet();
$labels->setTitle('Labels');
$this->fillQuestionLabelsSheet($labels, $season);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function fillBankQuestionsSheet(Worksheet $sheet, Season $season): void
{
$metaColumns = ['Question', 'Reusable', 'Complete for quiz', 'Labels', 'Used in quizzes'];
$sheet->fromArray($metaColumns, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$answerStartColumnIndex = \count($metaColumns);
$maxAnswers = 0;
$row = 2;
foreach ($season->bankQuestions as $bankQuestion) {
$labels = implode(', ', array_map(
static fn (QuestionLabel $label): string => $label->name,
$bankQuestion->labels->toArray(),
));
$usedInQuizzes = implode(', ', array_map(
static fn (BankQuestionUsage $usage): string => $usage->quiz->name,
$bankQuestion->usages->toArray(),
));
$sheet->fromArray([
$bankQuestion->question,
$bankQuestion->reusable ? 'Yes' : 'No',
$bankQuestion->isCompleteForQuiz ? 'Yes' : 'No',
$labels,
$usedInQuizzes,
], null, 'A'.$row);
$col = 0;
foreach ($bankQuestion->answers as $answer) {
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $col).$row, $answer->text);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $col).$row, $answer->isRightAnswer);
++$col;
}
$maxAnswers = max($maxAnswers, $col);
++$row;
}
for ($i = 0; $i < $maxAnswers; ++$i) {
$answerCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $i);
$correctCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $i);
$sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1));
$sheet->setCellValue($correctCol.'1', 'Correct');
}
$lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
private function fillQuestionLabelsSheet(Worksheet $sheet, Season $season): void
{
$sheet->fromArray(['Name', 'Colour', 'Slug'], null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($season->questionLabels as $label) {
$sheet->fromArray([$label->name, $label->colour->name, $label->slug], null, 'A'.$row);
++$row;
}
foreach (['A', 'B', 'C'] as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
/** @throws FilesystemException */
private function writeToTempFile(Spreadsheet $spreadsheet): string
{
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_sheet_');
try {
new Writer\Xlsx($spreadsheet)->save($path);
} catch (\Throwable $throwable) {
unlink($path);
throw $throwable;
}
return $path;
}
}
+8 -4
View File
@@ -7,6 +7,7 @@ namespace Tvdt\Service;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer; use PhpOffice\PhpSpreadsheet\Writer;
use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\File;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
@@ -117,8 +118,13 @@ class QuizSpreadsheetService
public function quizToXlsx(Quiz $quiz): \Closure public function quizToXlsx(Quiz $quiz): \Closure
{ {
$spreadsheet = new Spreadsheet(); $spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet(); $this->fillQuestionsSheet($spreadsheet->getActiveSheet(), $quiz);
return $this->toXlsx($spreadsheet);
}
public function fillQuestionsSheet(Worksheet $sheet, Quiz $quiz): void
{
// Write data rows first so we know the maximum answer count. // Write data rows first so we know the maximum answer count.
$maxAnswers = 0; $maxAnswers = 0;
$row = 2; $row = 2;
@@ -153,11 +159,9 @@ class QuizSpreadsheetService
$sheet->setCellValue($correctCol.'1', 'Correct'); $sheet->setCellValue($correctCol.'1', 'Correct');
$sheet->getColumnDimension($correctCol)->setAutoSize(true); $sheet->getColumnDimension($correctCol)->setAutoSize(true);
} }
return $this->toXlsx($spreadsheet);
} }
private function toXlsx(Spreadsheet $spreadsheet): \Closure public function toXlsx(Spreadsheet $spreadsheet): \Closure
{ {
$writer = new Writer\Xlsx($spreadsheet); $writer = new Writer\Xlsx($spreadsheet);
@@ -1,3 +1,4 @@
<h6>Kandidaten</h6> <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>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> <p>Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.</p>
<p>Gebruik het potlood-icoon om een kandidaat te hernoemen en het prullenbak-icoon om er een te verwijderen. Verwijderen gooit ook alle gegeven antwoorden van die kandidaat weg.</p>
@@ -1,3 +1,4 @@
<h6>Seizoensinstellingen</h6> <h6>Seizoensinstellingen</h6>
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p> <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> <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>
<p><strong>Seizoenscode:</strong> de code waarmee kandidaten dit seizoen kunnen vinden. Genereer een nieuwe code als de huidige per ongeluk gedeeld is, de oude code werkt daarna niet meer.</p>
@@ -4,9 +4,67 @@
<a class="btn btn-sm btn-outline-primary" <a class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a> href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
</div> </div>
<ul class="mb-3"> <ul class="list-group mb-3">
{% for candidate in season.candidates %} {% for candidate in season.candidates %}
<li>{{ candidate.name }}</li> <li class="list-group-item d-flex align-items-center justify-content-between gap-2">
{{ candidate.name }}
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal"
data-bs-target="#renameCandidate-{{ candidate.id }}"
title="{{ 'Rename'|trans }}"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
data-bs-target="#deleteCandidate-{{ candidate.id }}"
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
</div>
<div class="modal fade" id="renameCandidate-{{ candidate.id }}" data-bs-backdrop="static"
tabindex="-1" aria-labelledby="renameCandidate-{{ candidate.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_candidate_rename', {seasonCode: season.seasonCode, candidate: candidate.id}) }}"
method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="renameCandidate-{{ candidate.id }}Label">{{ 'Rename candidate'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<input type="hidden" name="_token" value="{{ csrf_token('rename_candidate') }}">
<label class="form-label" for="renameCandidateName-{{ candidate.id }}">{{ 'Name'|trans }}</label>
<input type="text" class="form-control" id="renameCandidateName-{{ candidate.id }}"
name="name" value="{{ candidate.name }}" maxlength="16" required autofocus>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Rename'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="deleteCandidate-{{ candidate.id }}" data-bs-backdrop="static"
tabindex="-1" aria-labelledby="deleteCandidate-{{ candidate.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="deleteCandidate-{{ candidate.id }}Label">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
{{ 'Are you sure you want to delete this candidate? All their answers will be lost.'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ path('tvdt_backoffice_candidate_delete', {seasonCode: season.seasonCode, candidate: candidate.id}) }}"
method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('delete_candidate') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
</li>
{% else %} {% else %}
{{ 'No candidates'|trans }} {{ 'No candidates'|trans }}
{% endfor %} {% endfor %}
@@ -1,8 +1,40 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ form(form) }} {{ form(form) }}
<hr>
<h4 class="text-danger">{{ 'Season code'|trans }}</h4>
<p>{{ 'The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.'|trans }}</p>
<p><strong>{{ 'Current code:'|trans }}</strong> {{ season.seasonCode }}</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#regenerateSeasonCodeModal">
{{ 'Regenerate season code...'|trans }}
</button>
</div> </div>
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ include('backoffice/help/season_settings.html.twig') }} {{ include('backoffice/help/season_settings.html.twig') }}
</div> </div>
</div> </div>
<div class="modal fade" id="regenerateSeasonCodeModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="regenerateSeasonCodeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_season_regenerate_code', {seasonCode: season.seasonCode}) }}" method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="regenerateSeasonCodeModalLabel">{{ 'Regenerate season code'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ 'This invalidates the current season code. Anyone using the old code will no longer be able to join this season.'|trans }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('regenerate_season_code') }}">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-danger">{{ 'Regenerate season code'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
@@ -8,11 +8,13 @@
</div> </div>
<div class="list-group mb-3"> <div class="list-group mb-3">
{% for quiz in season.quizzes %} {% for quiz in season.quizzes %}
<a class="list-group-item list-group-item-action{% if season.activeQuiz == quiz %} active{% endif %}" <a class="list-group-item list-group-item-action d-flex align-items-center gap-2{% if season.activeQuiz == quiz %} active{% endif %}"
href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"> href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">
{{ quiz.name }} {{ quiz.name }}
{% if quiz.isFinalized %} {% if season.activeQuiz == quiz %}
<span class="badge text-bg-success">{{ 'Finalized'|trans }}</span> <span class="badge text-bg-light ms-auto">{{ 'Active'|trans }}</span>
{% elseif quiz.isFinalized %}
<span class="badge text-bg-success ms-auto">{{ 'Ready'|trans }}</span>
{% endif %} {% endif %}
</a> </a>
{% else %} {% else %}
@@ -55,14 +55,13 @@
{{ form(emailForm, {action: path('tvdt_backoffice_settings_email')}) }} {{ form(emailForm, {action: path('tvdt_backoffice_settings_email')}) }}
</section> </section>
<section class="mb-5" data-controller="bo--popover"> <section class="mb-5">
<h4>{{ 'Your data'|trans }}</h4> <h4>{{ 'Your data'|trans }}</h4>
<span class="d-inline-block" tabindex="0" <p>{{ 'Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.'|trans }}</p>
data-bs-toggle="popover" {% if not app.user.isVerified %}
data-bs-trigger="hover focus" <p class="text-warning">{{ 'Confirm your email address to enable this feature.'|trans }}</p>
data-bs-content="{{ 'Soon™'|trans }}"> {% endif %}
<button type="button" class="btn btn-secondary pe-none" disabled>{{ 'Download data'|trans }}</button> <a class="btn btn-primary" href="{{ path('tvdt_backoffice_settings_download_data') }}">{{ 'Download data'|trans }}</a>
</span>
</section> </section>
<section class="mb-5"> <section class="mb-5">
+10
View File
@@ -1,3 +1,13 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('quiz') }}{% endblock %} {% block importmap %}{{ importmap('quiz') }}{% endblock %}
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %} {% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
{% block main %}
<div data-controller="fullscreen">
<button type="button"
class="fullscreen-btn"
data-action="fullscreen#toggle"
aria-label="{{ 'Fullscreen'|trans }}"
title="{{ 'Fullscreen'|trans }}">⛶</button>
{{ parent() }}
</div>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Backoffice'|trans }} {{ 'Backoffice'|trans }}
</a> </a>
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ path('tvdt_login_logout', {target: app.request.pathInfo}) }}" class="btn btn-outline-secondary btn-sm">
{{ 'Logout'|trans }} {{ 'Logout'|trans }}
</a> </a>
{% else %} {% else %}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\BackofficeController;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\User;
#[CoversClass(BackofficeController::class)]
final class BackofficeControllerTest extends WebTestCase
{
public function testExportQuizFilenameIsSanitized(): void
{
$client = self::createClient();
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$entityManager->flush();
$client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
$this->assertInstanceOf(Quiz::class, $quiz);
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseIsSuccessful();
$disposition = (string) $client->getResponse()->headers->get('Content-Disposition');
$this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
}
public function testExportQuizRequiresVerifiedEmail(): void
{
$client = self::createClient();
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
$client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
$this->assertInstanceOf(Quiz::class, $quiz);
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode));
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\SeasonController;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
#[CoversClass(SeasonController::class)]
final class SeasonControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
public function testRegenerateSeasonCodeChangesTheCode(): void
{
$oldCode = 'krtek';
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/%s/settings', $oldCode));
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
'_token' => $token,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $oldCode]));
$location = (string) $this->client->getResponse()->headers->get('Location');
$this->assertMatchesRegularExpression('#^/backoffice/season/[a-z]{5}/settings$#', $location);
}
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/settings');
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
'_token' => $token,
]);
self::assertResponseStatusCodeSame(403);
}
private function getCandidate(string $name): Candidate
{
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Candidate::class, $candidate);
return $candidate;
}
private function getCsrfTokenFromCandidatesTab(string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/candidates');
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testRenameCandidate(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Tommy',
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$renamed = $this->entityManager->getRepository(Candidate::class)->find($candidate->id);
$this->assertInstanceOf(Candidate::class, $renamed);
$this->assertSame('Tommy', $renamed->name);
}
public function testRenameCandidateToExistingNameShowsError(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Claudia',
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$unchanged = $this->entityManager->getRepository(Candidate::class)->find($candidate->id);
$this->assertInstanceOf(Candidate::class, $unchanged);
$this->assertSame('Tom', $unchanged->name);
}
public function testDeleteCandidate(): void
{
$candidate = $this->getCandidate('Tom');
$candidateId = $candidate->id;
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/delete', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$this->assertNotInstanceOf(Candidate::class, $this->entityManager->getRepository(Candidate::class)->find($candidateId));
}
public function testRenameCandidateIsDeniedForNonOwner(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Tommy',
]);
self::assertResponseStatusCodeSame(403);
}
}
@@ -350,4 +350,47 @@ final class SettingsControllerTest extends WebTestCase
$this->assertNotEmpty($ownerEmails); $this->assertNotEmpty($ownerEmails);
} }
} }
public function testDownloadDataRequiresAuthentication(): void
{
$this->client->restart();
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseRedirects();
}
public function testDownloadDataReturnsAZipWithATimestampedAccountFilename(): void
{
$this->markUserVerified('test@example.org');
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'application/zip');
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
$this->assertMatchesRegularExpression(
'/filename=tijd-voor-de-test-data-test-example-org-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip/',
$disposition,
);
}
public function testDownloadDataRequiresVerifiedEmail(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseRedirects('/backoffice/settings');
}
private function markUserVerified(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$this->entityManager->flush();
}
} }
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\TestCase;
use Tvdt\Helpers\FilenameSanitizer;
final class FilenameSanitizerTest extends TestCase
{
public function testReplacesSpacesWithDashes(): void
{
$this->assertSame('Krtek-Weekend', FilenameSanitizer::sanitize('Krtek Weekend'));
}
public function testStripsPathSeparatorsAndTraversal(): void
{
$this->assertSame('etc-passwd', FilenameSanitizer::sanitize('../../etc/passwd'));
$this->assertSame('a-b', FilenameSanitizer::sanitize('a/b'));
$this->assertSame('a-b', FilenameSanitizer::sanitize('a\\b'));
}
public function testStripsControlCharactersAndSpecialSymbols(): void
{
$this->assertSame('Quiz-1-script', FilenameSanitizer::sanitize("Quiz #1 <script>\0"));
}
public function testTransliteratesUnicodeToAscii(): void
{
$this->assertSame('Weird-Name', FilenameSanitizer::sanitize('Wéird Ñame'));
}
public function testTransliteratesAtSignInEmail(): void
{
$this->assertSame('test-example-org', FilenameSanitizer::sanitize('test@example.org'));
}
public function testReturnsUnnamedForEmptyOrFullyStrippedInput(): void
{
$this->assertSame('unnamed', FilenameSanitizer::sanitize(''));
$this->assertSame('unnamed', FilenameSanitizer::sanitize('///'));
}
}
+91
View File
@@ -7,6 +7,12 @@ namespace Tvdt\Tests\Repository;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\DataFixtures\TestFixtures; use Tvdt\DataFixtures\TestFixtures;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Repository\UserRepository; use Tvdt\Repository\UserRepository;
use function PHPUnit\Framework\assertEmpty; use function PHPUnit\Framework\assertEmpty;
@@ -42,4 +48,89 @@ final class UserRepositoryTest extends DatabaseTestCase
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);
$this->userRepository->makeAdmin('invalid@example.org'); $this->userRepository->makeAdmin('invalid@example.org');
} }
/**
* GDPR right-to-erasure: deleting the sole owner of a season must physically remove every
* row tied to it, not merely soft-delete it. QuizCandidate, GivenAnswer, and Elimination are
* all Gedmo\SoftDeleteable, so a naive $em->remove($season) cascade leaves them (or the
* transaction itself) behind. Assertions bypass the softdeleteable filter and read raw SQL,
* since a soft-deleted row would otherwise still be invisible to a filtered ORM query.
*/
public function testDeleteUserHardDeletesQuizCandidateGivenAnswerAndElimination(): void
{
$user = $this->getUserByEmail('sole-owner@example.org');
$season = $this->getSeasonByCode('doomd');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Vera');
/** @var Question $question */
$question = $quiz->questions->first();
$rightAnswer = $question->answers->first();
$this->assertNotFalse($rightAnswer);
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
$quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]);
$this->assertInstanceOf(QuizCandidate::class, $quizCandidate);
$givenAnswer = new GivenAnswer($candidate, $quiz, $rightAnswer);
$this->entityManager->persist($givenAnswer);
$elimination = new Elimination($quiz);
$elimination->data = ['Vera' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$quizCandidateId = $quizCandidate->id->toString();
$givenAnswerId = $givenAnswer->id->toString();
$eliminationId = $elimination->id->toString();
$this->userRepository->deleteUser($user);
$this->entityManager->clear();
$connection = $this->entityManager->getConnection();
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from quiz_candidate where id = ?', [$quizCandidateId]));
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from given_answer where id = ?', [$givenAnswerId]));
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from elimination where id = ?', [$eliminationId]));
}
/**
* Gedmo\Loggable writes an audit row (including the editor's username/email) to
* ext_log_entries for every change to a Versioned field. Those rows aren't linked via a
* foreign key (object_id is a plain string), so deleting the season/BankQuestion never
* cleans them up on its own the deleted account's email would otherwise live on forever.
*/
public function testDeleteUserPurgesBankQuestionAuditLogEntries(): void
{
$user = $this->getUserByEmail('sole-owner@example.org');
$season = $this->getSeasonByCode('doomd');
$bankQuestion = new BankQuestion();
$bankQuestion->question = 'Wie is de Krtek eigenlijk?';
$bankQuestion->season = $season;
$this->entityManager->persist($bankQuestion);
$this->entityManager->flush();
$bankQuestionId = $bankQuestion->id->toString();
$connection = $this->entityManager->getConnection();
$logCountBefore = (int) $connection->fetchOne(
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
[BankQuestion::class, $bankQuestionId],
);
$this->assertGreaterThan(0, $logCountBefore);
$this->userRepository->deleteUser($user);
$this->entityManager->clear();
$logCountAfter = (int) $connection->fetchOne(
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
[BankQuestion::class, $bankQuestionId],
);
$this->assertSame(0, $logCountAfter);
}
} }
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Security;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
use Tvdt\Security\LogoutRedirectListener;
#[CoversClass(LogoutRedirectListener::class)]
final class LogoutRedirectListenerTest extends TestCase
{
public function testLogoutRedirectsBackToTheGivenTarget(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$listener = new LogoutRedirectListener($urlGenerator);
$event = new LogoutEvent(Request::create('/logout?target=/krtek'), null);
$listener->onLogout($event);
$this->assertSame('/krtek', $event->getResponse()?->headers->get('Location'));
}
public function testLogoutWithoutTargetRedirectsToSeasonSelect(): void
{
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
$event = new LogoutEvent(Request::create('/logout'), null);
$listener->onLogout($event);
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
}
#[DataProvider('blockedTargetProvider')]
public function testLogoutIgnoresBlockedOrUnsafeTargets(string $target): void
{
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
$event = new LogoutEvent(Request::create('/logout?target='.urlencode($target)), null);
$listener->onLogout($event);
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
}
/** @return iterable<string, array{string}> */
public static function blockedTargetProvider(): iterable
{
yield 'backoffice page' => ['/backoffice/season/krtek'];
yield 'elimination page' => ['/elimination/00000000-0000-0000-0000-000000000000'];
yield 'protocol-relative url' => ['//evil.example.org'];
yield 'absolute url' => ['https://evil.example.org'];
}
private function seasonSelectUrlGenerator(): UrlGeneratorInterface&MockObject
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->once())
->method('generate')
->with('tvdt_quiz_select_season')
->willReturn('/');
return $urlGenerator;
}
}
+286
View File
@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PHPUnit\Framework\Attributes\CoversClass;
use Tvdt\Entity\Answer;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\User;
use Tvdt\Service\DataExportService;
use Tvdt\Tests\Repository\DatabaseTestCase;
use function Safe\file_put_contents;
use function Safe\tempnam;
use function Safe\unlink;
#[CoversClass(DataExportService::class)]
final class DataExportServiceTest extends DatabaseTestCase
{
private DataExportService $subject;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
parent::setUp();
$this->subject = self::getContainer()->get(DataExportService::class);
}
protected function tearDown(): void
{
foreach ($this->tempFiles as $path) {
if (file_exists($path)) {
unlink($path);
}
}
parent::tearDown();
}
public function testExportForUserWithNoSeasonsContainsOnlyProfile(): void
{
$zip = $this->openZip($this->getUserByEmail('test@example.org'));
$this->assertSame(1, $zip->numFiles);
$this->assertNotFalse($zip->locateName('profile.xlsx'));
$zip->close();
}
public function testExportForUserIncludesOwnedSeasonsQuizzesAndCandidates(): void
{
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$names = $this->entryNames($zip);
$this->assertContains('profile.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/Quiz-1.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/Quiz-2.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/candidates.xlsx', $names);
$this->assertContains('krtek-Krtek-Weekend/question-bank.xlsx', $names);
$this->assertContains('bbbbb-Another-Season/candidates.xlsx', $names);
$this->assertContains('bbbbb-Another-Season/question-bank.xlsx', $names);
// Another Season has no quizzes, so no quiz xlsx should be present for it.
foreach ($names as $name) {
$this->assertStringStartsNotWith('bbbbb-Another-Season/Quiz', $name);
}
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$this->assertSame(['Quiz info', 'Questions', 'Raw answers', 'Results', 'Eliminations'], $this->sheetNames($quizContent));
$candidatesContent = $zip->getFromName('krtek-Krtek-Weekend/candidates.xlsx');
$this->assertIsString($candidatesContent);
$this->assertSame(['Candidates', 'Season info'], $this->sheetNames($candidatesContent));
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
$this->assertIsString($questionBankContent);
$this->assertSame(['Questions', 'Labels'], $this->sheetNames($questionBankContent));
$zip->close();
}
public function testQuestionBankSheetIncludesBankQuestionsAndUsage(): void
{
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
$this->assertIsString($questionBankContent);
$zip->close();
$rows = $this->loadSheet($questionBankContent, 'Questions')->toArray();
$header = $rows[0];
$dataRows = \array_slice($rows, 1);
$questionIndex = array_search('Question', $header, true);
$reusableIndex = array_search('Reusable', $header, true);
$labelsIndex = array_search('Labels', $header, true);
$usedInQuizzesIndex = array_search('Used in quizzes', $header, true);
$reusableRow = current(array_filter($dataRows, static fn (array $row): bool => 'Wie is de Krtek?' === $row[$questionIndex]));
$this->assertIsArray($reusableRow);
$this->assertSame('Yes', $reusableRow[$reusableIndex]);
$this->assertSame('Finale', $reusableRow[$labelsIndex]);
$usedRow = current(array_filter($dataRows, static fn (array $row): bool => 'Waar sliep de Krtek?' === $row[$questionIndex]));
$this->assertIsArray($usedRow);
$this->assertSame('Quiz 2', $usedRow[$usedInQuizzesIndex]);
$labelRows = $this->loadSheet($questionBankContent, 'Labels')->toArray();
$labelNames = array_column(\array_slice($labelRows, 1), 0);
$this->assertContains('Locatie', $labelNames);
$this->assertContains('Finale', $labelNames);
}
public function testProfileSheetDoesNotContainPasswordHash(): void
{
$user = $this->getUserByEmail('user2@example.org');
$zip = $this->openZip($user);
$profileContent = $zip->getFromName('profile.xlsx');
$this->assertIsString($profileContent);
$zip->close();
$rows = $this->loadSheet($profileContent, 'Account')->toArray();
$flattened = implode(' ', array_merge(...$rows));
$this->assertStringNotContainsString($user->password, $flattened);
}
public function testResultsSheetIncludesSoftDeletedQuizCandidates(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$this->entityManager->persist($quizCandidate);
$this->entityManager->flush();
$this->entityManager->remove($quizCandidate);
$this->entityManager->flush();
$this->entityManager->clear();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Results')->toArray();
$deletedColumnIndex = array_search('Deleted', $rows[0], true);
$this->assertIsInt($deletedColumnIndex);
$hasDeletedRow = array_any(\array_slice($rows, 1), static fn (array $row): bool => null !== $row[$deletedColumnIndex] && '' !== $row[$deletedColumnIndex]);
$this->assertTrue($hasDeletedRow, 'Expected the soft-deleted QuizCandidate to still appear with a Deleted timestamp');
}
public function testRawAnswersSheetShowsCandidatesByQuestionsGrid(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
/** @var Question $firstQuestion */
$firstQuestion = $quiz->questions->first();
$chosenAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => 'Man' === $answer->text)->first();
$this->assertInstanceOf(Answer::class, $chosenAnswer);
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
$givenAnswer = new GivenAnswer($candidate, $quiz, $chosenAnswer);
$this->entityManager->persist($givenAnswer);
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Raw answers')->toArray();
$header = $rows[0];
$this->assertSame('Candidate', $header[0]);
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
$this->assertIsInt($questionColumnIndex);
$claudiaRow = current(array_filter(
\array_slice($rows, 1),
static fn (array $row): bool => 'Claudia' === $row[0],
));
$this->assertIsArray($claudiaRow);
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
}
public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
$this->assertTrue($quiz->isFinalized);
/** @var Question $disabledQuestion */
$disabledQuestion = $quiz->questions->first();
$disabledQuestion->enabled = false;
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$rows = $this->loadSheet($quizContent, 'Quiz info')->toArray();
$values = [];
foreach ($rows as $row) {
$values[$row[0]] = $row[1];
}
$this->assertSame('Quiz 1', $values['Quiz name']);
$this->assertSame($quiz->dropouts, (int) $values['Number of dropouts']);
$this->assertSame('Yes', $values['Finalized']);
$this->assertNotEmpty($values['Finalized at']);
$this->assertStringContainsString($disabledQuestion->question, (string) $values['Disabled questions']);
}
private function openZip(User $user): \ZipArchive
{
$zipPath = $this->subject->exportForUser($user);
$this->tempFiles[] = $zipPath;
$zip = new \ZipArchive();
$this->assertTrue($zip->open($zipPath));
return $zip;
}
/** @return list<string> */
private function entryNames(\ZipArchive $zip): array
{
$names = [];
for ($i = 0; $i < $zip->numFiles; ++$i) {
$name = $zip->getNameIndex($i);
$this->assertIsString($name);
$names[] = $name;
}
return $names;
}
/** @return list<string> */
private function sheetNames(string $xlsxContent): array
{
$path = $this->createTempPath();
file_put_contents($path, $xlsxContent);
return array_values(new Reader\Xlsx()->load($path)->getSheetNames());
}
private function loadSheet(string $xlsxContent, string $sheetName): Worksheet
{
$path = $this->createTempPath();
file_put_contents($path, $xlsxContent);
$sheet = new Reader\Xlsx()->load($path)->getSheetByName($sheetName);
$this->assertInstanceOf(Worksheet::class, $sheet);
return $sheet;
}
private function createTempPath(): string
{
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_test_');
$this->tempFiles[] = $path;
return $path;
}
}
+100 -8
View File
@@ -5,6 +5,10 @@
<tool tool-id="symfony" tool-name="Symfony"/> <tool tool-id="symfony" tool-name="Symfony"/>
</header> </header>
<body> <body>
<trans-unit id="QcFeGZy" resname="A candidate with this name already exists in this season">
<source>A candidate with this name already exists in this season</source>
<target>Er bestaat al een kandidaat met deze naam in dit seizoen</target>
</trans-unit>
<trans-unit id="VNxXghX" resname="A label with a similar name already exists"> <trans-unit id="VNxXghX" resname="A label with a similar name already exists">
<source>A label with a similar name already exists</source> <source>A label with a similar name already exists</source>
<target>Er bestaat al een label met deze naam</target> <target>Er bestaat al een label met deze naam</target>
@@ -101,6 +105,10 @@
<source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source> <source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source>
<target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target> <target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>
</trans-unit> </trans-unit>
<trans-unit id="zisWo9d" resname="Are you sure you want to delete this candidate? All their answers will be lost.">
<source>Are you sure you want to delete this candidate? All their answers will be lost.</source>
<target>Weet je zeker dat je deze kandidaat wilt verwijderen? Alle gegeven antwoorden gaan dan verloren.</target>
</trans-unit>
<trans-unit id="8HZ5s3T" resname="Are you sure you want to delete this question from the question bank?"> <trans-unit id="8HZ5s3T" resname="Are you sure you want to delete this question from the question bank?">
<source>Are you sure you want to delete this question from the question bank?</source> <source>Are you sure you want to delete this question from the question bank?</source>
<target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target> <target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target>
@@ -145,10 +153,18 @@
<source>Candidate answers saved</source> <source>Candidate answers saved</source>
<target>Kandidaatantwoorden opgeslagen</target> <target>Kandidaatantwoorden opgeslagen</target>
</trans-unit> </trans-unit>
<trans-unit id="tggdgJl" resname="Candidate deleted">
<source>Candidate deleted</source>
<target>Kandidaat verwijderd</target>
</trans-unit>
<trans-unit id="TiTLBGW" resname="Candidate not found"> <trans-unit id="TiTLBGW" resname="Candidate not found">
<source>Candidate not found</source> <source>Candidate not found</source>
<target>Kandidaat niet gevonden</target> <target>Kandidaat niet gevonden</target>
</trans-unit> </trans-unit>
<trans-unit id="QH4e_Ho" resname="Candidate renamed">
<source>Candidate renamed</source>
<target>Kandidaat hernoemd</target>
</trans-unit>
<trans-unit id="6QiGbuz" resname="Candidate status updated"> <trans-unit id="6QiGbuz" resname="Candidate status updated">
<source>Candidate status updated</source> <source>Candidate status updated</source>
<target>Kandidaatstatus bijgewerkt</target> <target>Kandidaatstatus bijgewerkt</target>
@@ -189,6 +205,14 @@
<source>Confirm Answers</source> <source>Confirm Answers</source>
<target>Bevestig antwoorden</target> <target>Bevestig antwoorden</target>
</trans-unit> </trans-unit>
<trans-unit id="T1Z6nI1" resname="Confirm your email address to enable this feature.">
<source>Confirm your email address to enable this feature.</source>
<target>Bevestig je e-mailadres om deze functie te kunnen gebruiken.</target>
</trans-unit>
<trans-unit id="QZfvKMx" resname="Confirm your email address to enable this.">
<source>Confirm your email address to enable this.</source>
<target>Bevestig je e-mailadres om dit te gebruiken.</target>
</trans-unit>
<trans-unit id="PiAVEe9" resname="Confirmed"> <trans-unit id="PiAVEe9" resname="Confirmed">
<source>Confirmed</source> <source>Confirmed</source>
<target>Bevestigd</target> <target>Bevestigd</target>
@@ -221,6 +245,10 @@
<source>Create an empty quiz and add questions from the question bank.</source> <source>Create an empty quiz and add questions from the question bank.</source>
<target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target> <target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target>
</trans-unit> </trans-unit>
<trans-unit id="jrXhJOR" resname="Current code:">
<source>Current code:</source>
<target>Huidige code:</target>
</trans-unit>
<trans-unit id="3leUyoA" resname="Current email address:"> <trans-unit id="3leUyoA" resname="Current email address:">
<source>Current email address:</source> <source>Current email address:</source>
<target>Huidig e-mailadres:</target> <target>Huidig e-mailadres:</target>
@@ -273,6 +301,10 @@
<source>Download Template</source> <source>Download Template</source>
<target>Download sjabloon</target> <target>Download sjabloon</target>
</trans-unit> </trans-unit>
<trans-unit id="43g0Dc8" resname="Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.">
<source>Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.</source>
<target>Download een archief met alles wat onder je account is opgeslagen: je profiel, de seizoenen die je bezit, met de bijbehorende testen, resultaten en kandidaten.</target>
</trans-unit>
<trans-unit id="58e2QWG" resname="Download data"> <trans-unit id="58e2QWG" resname="Download data">
<source>Download data</source> <source>Download data</source>
<target>Gegevens downloaden</target> <target>Gegevens downloaden</target>
@@ -337,6 +369,10 @@
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target>Wachtwoord vergeten?</target> <target>Wachtwoord vergeten?</target>
</trans-unit> </trans-unit>
<trans-unit id="3cWWP_q" resname="Fullscreen">
<source>Fullscreen</source>
<target>Volledig scherm</target>
</trans-unit>
<trans-unit id="MebBrmp" resname="Gray"> <trans-unit id="MebBrmp" resname="Gray">
<source>Gray</source> <source>Gray</source>
<target>Grijs</target> <target>Grijs</target>
@@ -537,6 +573,18 @@
<source>Please Confirm your Email</source> <source>Please Confirm your Email</source>
<target>Bevestig je e-mailadres alsjeblieft</target> <target>Bevestig je e-mailadres alsjeblieft</target>
</trans-unit> </trans-unit>
<trans-unit id="7osID7L" resname="Please confirm your email address before downloading your data.">
<source>Please confirm your email address before downloading your data.</source>
<target>Bevestig eerst je e-mailadres voordat je je gegevens downloadt.</target>
</trans-unit>
<trans-unit id="XS4opCD" resname="Please confirm your email address before exporting a quiz.">
<source>Please confirm your email address before exporting a quiz.</source>
<target>Bevestig je e-mailadres voordat je een quiz exporteert.</target>
</trans-unit>
<trans-unit id="y4TgBCP" resname="Please confirm your email address before exporting data.">
<source>Please confirm your email address before exporting data.</source>
<target>Bevestig eerst je e-mailadres voordat je gegevens exporteert.</target>
</trans-unit>
<trans-unit id="mq1QYAv" resname="Please select an answer"> <trans-unit id="mq1QYAv" resname="Please select an answer">
<source>Please select an answer</source> <source>Please select an answer</source>
<target>Selecteer een antwoorden alsjeblieft</target> <target>Selecteer een antwoorden alsjeblieft</target>
@@ -661,6 +709,10 @@
<source>Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.</source> <source>Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.</source>
<target>Heropent de test voor bewerking. Deelnemers kunnen de test niet meer afnemen totdat deze opnieuw is afgerond.</target> <target>Heropent de test voor bewerking. Deelnemers kunnen de test niet meer afnemen totdat deze opnieuw is afgerond.</target>
</trans-unit> </trans-unit>
<trans-unit id="MuwD4xO" resname="Ready">
<source>Ready</source>
<target>Voorbereid</target>
</trans-unit>
<trans-unit id="P1HcfAu" resname="Red"> <trans-unit id="P1HcfAu" resname="Red">
<source>Red</source> <source>Red</source>
<target>Rood</target> <target>Rood</target>
@@ -669,6 +721,14 @@
<source>Refresh the page to try again.</source> <source>Refresh the page to try again.</source>
<target>Ververs de pagina om het opnieuw te proberen.</target> <target>Ververs de pagina om het opnieuw te proberen.</target>
</trans-unit> </trans-unit>
<trans-unit id="EGW_4W_" resname="Regenerate season code">
<source>Regenerate season code</source>
<target>Seizoenscode opnieuw genereren</target>
</trans-unit>
<trans-unit id="EWuNNNu" resname="Regenerate season code...">
<source>Regenerate season code...</source>
<target>Seizoenscode opnieuw genereren...</target>
</trans-unit>
<trans-unit id="fGfBzt6" resname="Register"> <trans-unit id="fGfBzt6" resname="Register">
<source>Register</source> <source>Register</source>
<target>Registreren</target> <target>Registreren</target>
@@ -681,6 +741,14 @@
<source>Remove label</source> <source>Remove label</source>
<target>Label verwijderen</target> <target>Label verwijderen</target>
</trans-unit> </trans-unit>
<trans-unit id="WHywg0z" resname="Rename">
<source>Rename</source>
<target>Hernoemen</target>
</trans-unit>
<trans-unit id="K2RP6H8" resname="Rename candidate">
<source>Rename candidate</source>
<target>Kandidaat hernoemen</target>
</trans-unit>
<trans-unit id="Z9CSKpk" resname="Repeat Password"> <trans-unit id="Z9CSKpk" resname="Repeat Password">
<source>Repeat Password</source> <source>Repeat Password</source>
<target>Herhaal wachtwoord</target> <target>Herhaal wachtwoord</target>
@@ -729,6 +797,14 @@
<source>Season Name</source> <source>Season Name</source>
<target>Seizoennaam</target> <target>Seizoennaam</target>
</trans-unit> </trans-unit>
<trans-unit id="IxKDF4a" resname="Season code">
<source>Season code</source>
<target>Seizoenscode</target>
</trans-unit>
<trans-unit id="iigT2eM" resname="Season code regenerated">
<source>Season code regenerated</source>
<target>Seizoenscode opnieuw gegenereerd</target>
</trans-unit>
<trans-unit id="kc_J96C" resname="Seasons"> <trans-unit id="kc_J96C" resname="Seasons">
<source>Seasons</source> <source>Seasons</source>
<target>Seizoenen</target> <target>Seizoenen</target>
@@ -769,6 +845,18 @@
<source>Sync latest changes to this quiz</source> <source>Sync latest changes to this quiz</source>
<target>Laatste wijzigingen synchroniseren naar deze quiz</target> <target>Laatste wijzigingen synchroniseren naar deze quiz</target>
</trans-unit> </trans-unit>
<trans-unit id="cug5d45" resname="The candidate name must be between 1 and 16 characters">
<source>The candidate name must be between 1 and 16 characters</source>
<target>De naam van de kandidaat moet tussen de 1 en 16 tekens zijn</target>
</trans-unit>
<trans-unit id="XwQ1Fav" resname="The confirmation email could not be sent. Please try again later.">
<source>The confirmation email could not be sent. Please try again later.</source>
<target>De bevestigingsmail kon niet worden verzonden. Probeer het later opnieuw.</target>
</trans-unit>
<trans-unit id="mMDwcxj" resname="The confirmation email could not be sent. Please use the resend button to try again.">
<source>The confirmation email could not be sent. Please use the resend button to try again.</source>
<target>De bevestigingsmail kon niet worden verzonden. Gebruik de knop om het opnieuw te proberen.</target>
</trans-unit>
<trans-unit id="_z4el3Z" resname="The password fields must match."> <trans-unit id="_z4el3Z" resname="The password fields must match.">
<source>The password fields must match.</source> <source>The password fields must match.</source>
<target>De wachtwoorden moeten overeen komen.</target> <target>De wachtwoorden moeten overeen komen.</target>
@@ -793,6 +881,10 @@
<source>The quiz must be finalized before it can be activated</source> <source>The quiz must be finalized before it can be activated</source>
<target>De test moet afgerond zijn voordat deze geactiveerd kan worden</target> <target>De test moet afgerond zijn voordat deze geactiveerd kan worden</target>
</trans-unit> </trans-unit>
<trans-unit id="IKPt_Pf" resname="The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.">
<source>The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.</source>
<target>De seizoenscode wordt door kandidaten gebruikt om dit seizoen te vinden. Als je een nieuwe code genereert, werkt de oude niet meer, dus deel de nieuwe code opnieuw.</target>
</trans-unit>
<trans-unit id="HuzRgeN" resname="There are no answers for this question"> <trans-unit id="HuzRgeN" resname="There are no answers for this question">
<source>There are no answers for this question</source> <source>There are no answers for this question</source>
<target>Er zijn geen antwoorden voor deze vraag</target> <target>Er zijn geen antwoorden voor deze vraag</target>
@@ -809,6 +901,10 @@
<source>This deletes your account and every season you are the only owner of. Enter your password to confirm.</source> <source>This deletes your account and every season you are the only owner of. Enter your password to confirm.</source>
<target>Dit verwijdert je account en alle seizoenen waarvan jij de enige eigenaar bent. Vul je wachtwoord in om te bevestigen.</target> <target>Dit verwijdert je account en alle seizoenen waarvan jij de enige eigenaar bent. Vul je wachtwoord in om te bevestigen.</target>
</trans-unit> </trans-unit>
<trans-unit id="EBWUWJu" resname="This invalidates the current season code. Anyone using the old code will no longer be able to join this season.">
<source>This invalidates the current season code. Anyone using the old code will no longer be able to join this season.</source>
<target>Hiermee wordt de huidige seizoenscode ongeldig. Iedereen die de oude code gebruikt, kan dit seizoen niet meer vinden.</target>
</trans-unit>
<trans-unit id="YueaA2f" resname="This link will expire in %count%."> <trans-unit id="YueaA2f" resname="This link will expire in %count%.">
<source>This link will expire in %count%.</source> <source>This link will expire in %count%.</source>
<target>Deze link verloopt over %count%.</target> <target>Deze link verloopt over %count%.</target>
@@ -901,18 +997,14 @@
<source>Your data</source> <source>Your data</source>
<target>Je gegevens</target> <target>Je gegevens</target>
</trans-unit> </trans-unit>
<trans-unit id="sAb9l8I" resname="Your email address has been changed.">
<source>Your email address has been changed.</source>
<target>Je e-mailadres is gewijzigd.</target>
</trans-unit>
<trans-unit id="OqDtnJw" resname="Your email address has been changed. Please check your inbox to confirm it."> <trans-unit id="OqDtnJw" resname="Your email address has been changed. Please check your inbox to confirm it.">
<source>Your email address has been changed. Please check your inbox to confirm it.</source> <source>Your email address has been changed. Please check your inbox to confirm it.</source>
<target>Je e-mailadres is gewijzigd. Check je inbox om het te bevestigen.</target> <target>Je e-mailadres is gewijzigd. Check je inbox om het te bevestigen.</target>
</trans-unit> </trans-unit>
<trans-unit id="kWpL7Rn" resname="The confirmation email could not be sent. Please use the resend button to try again.">
<source>The confirmation email could not be sent. Please use the resend button to try again.</source>
<target>De bevestigingsmail kon niet worden verzonden. Gebruik de knop om het opnieuw te proberen.</target>
</trans-unit>
<trans-unit id="mQxV2Jf" resname="The confirmation email could not be sent. Please try again later.">
<source>The confirmation email could not be sent. Please try again later.</source>
<target>De bevestigingsmail kon niet worden verzonden. Probeer het later opnieuw.</target>
</trans-unit>
<trans-unit id="m80cBv0" resname="Your email address has been verified."> <trans-unit id="m80cBv0" resname="Your email address has been verified.">
<source>Your email address has been verified.</source> <source>Your email address has been verified.</source>
<target>Je e-mailadres is geverifieerd.</target> <target>Je e-mailadres is geverifieerd.</target>