mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-10 01:20:14 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7028c972 | |||
| 0ee15e3cbb | |||
| 352e34a428 | |||
| b1a959fdc6 | |||
| 27d3d64154 | |||
| e7586c2d6b | |||
| a49d32e8ff | |||
| 4547a43199 |
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('///'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user