Compare commits

..

6 Commits

Author SHA1 Message Date
Marijn 352e34a428 feat: add candidate rename and delete (#194)
Adds per-candidate rename and delete actions to the candidates tab,
guarded by confirmation modals since deletion also discards the
candidate's given answers.

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

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

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

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

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

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

* fix: right-align quiz status badges

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

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

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

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

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

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 23:55:07 +00:00
10 changed files with 428 additions and 32 deletions
Generated
+19 -19
View File
@@ -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",
@@ -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
View File
@@ -19,6 +19,12 @@ class CandidateRepository extends ServiceEntityRepository
parent::__construct($registry, Candidate::class); parent::__construct($registry, Candidate::class);
} }
public function deleteCandidate(Candidate $candidate): void
{
$this->getEntityManager()->remove($candidate);
$this->getEntityManager()->flush();
}
public function getCandidateByHash(Season $season, string $hash): ?Candidate public function getCandidateByHash(Season $season, string $hash): ?Candidate
{ {
try { try {
@@ -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 %}
@@ -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);
}
}
+72 -8
View File
@@ -5,6 +5,10 @@
<tool tool-id="symfony" tool-name="Symfony"/> <tool tool-id="symfony" tool-name="Symfony"/>
</header> </header>
<body> <body>
<trans-unit id="QcFeGZy" resname="A candidate with this name already exists in this season">
<source>A candidate with this name already exists in this season</source>
<target>Er bestaat al een kandidaat met deze naam in dit seizoen</target>
</trans-unit>
<trans-unit id="VNxXghX" resname="A label with a similar name already exists"> <trans-unit id="VNxXghX" resname="A label with a similar name already exists">
<source>A label with a similar name already exists</source> <source>A label with a similar name already exists</source>
<target>Er bestaat al een label met deze naam</target> <target>Er bestaat al een label met deze naam</target>
@@ -101,6 +105,10 @@
<source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source> <source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source>
<target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target> <target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>
</trans-unit> </trans-unit>
<trans-unit id="zisWo9d" resname="Are you sure you want to delete this candidate? All their answers will be lost.">
<source>Are you sure you want to delete this candidate? All their answers will be lost.</source>
<target>Weet je zeker dat je deze kandidaat wilt verwijderen? Alle gegeven antwoorden gaan dan verloren.</target>
</trans-unit>
<trans-unit id="8HZ5s3T" resname="Are you sure you want to delete this question from the question bank?"> <trans-unit id="8HZ5s3T" resname="Are you sure you want to delete this question from the question bank?">
<source>Are you sure you want to delete this question from the question bank?</source> <source>Are you sure you want to delete this question from the question bank?</source>
<target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target> <target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target>
@@ -145,10 +153,18 @@
<source>Candidate answers saved</source> <source>Candidate answers saved</source>
<target>Kandidaatantwoorden opgeslagen</target> <target>Kandidaatantwoorden opgeslagen</target>
</trans-unit> </trans-unit>
<trans-unit id="tggdgJl" resname="Candidate deleted">
<source>Candidate deleted</source>
<target>Kandidaat verwijderd</target>
</trans-unit>
<trans-unit id="TiTLBGW" resname="Candidate not found"> <trans-unit id="TiTLBGW" resname="Candidate not found">
<source>Candidate not found</source> <source>Candidate not found</source>
<target>Kandidaat niet gevonden</target> <target>Kandidaat niet gevonden</target>
</trans-unit> </trans-unit>
<trans-unit id="QH4e_Ho" resname="Candidate renamed">
<source>Candidate renamed</source>
<target>Kandidaat hernoemd</target>
</trans-unit>
<trans-unit id="6QiGbuz" resname="Candidate status updated"> <trans-unit id="6QiGbuz" resname="Candidate status updated">
<source>Candidate status updated</source> <source>Candidate status updated</source>
<target>Kandidaatstatus bijgewerkt</target> <target>Kandidaatstatus bijgewerkt</target>
@@ -221,6 +237,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>
@@ -661,6 +681,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 +693,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 +713,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 +769,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 +817,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 +853,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 +873,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 +969,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>