diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php
index fd1dbbc..32f2e8d 100644
--- a/src/Controller/Backoffice/SeasonController.php
+++ b/src/Controller/Backoffice/SeasonController.php
@@ -10,10 +10,13 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
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\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -26,6 +29,7 @@ use Tvdt\Enum\FlashType;
use Tvdt\Form\AddCandidatesFormType;
use Tvdt\Form\SettingsForm;
use Tvdt\Form\UploadQuizFormType;
+use Tvdt\Repository\CandidateRepository;
use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService;
@@ -37,6 +41,7 @@ class SeasonController extends AbstractController
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em,
private readonly QuizSpreadsheetService $quizSpreadsheet,
+ private readonly CandidateRepository $candidateRepository,
) {}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -123,6 +128,56 @@ class SeasonController extends AbstractController
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')]
#[Route(
'/backoffice/season/{seasonCode:season}/add-quiz',
diff --git a/src/Repository/CandidateRepository.php b/src/Repository/CandidateRepository.php
index 8c8bf20..ccf6fe2 100644
--- a/src/Repository/CandidateRepository.php
+++ b/src/Repository/CandidateRepository.php
@@ -19,6 +19,12 @@ class CandidateRepository extends ServiceEntityRepository
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
{
try {
diff --git a/templates/backoffice/help/nl/season_candidates.html.twig b/templates/backoffice/help/nl/season_candidates.html.twig
index bfabbb6..1ba7720 100644
--- a/templates/backoffice/help/nl/season_candidates.html.twig
+++ b/templates/backoffice/help/nl/season_candidates.html.twig
@@ -1,3 +1,4 @@
Kandidaten
Dit zijn de spelers van dit seizoen. Voeg alle deelnemers toe voordat je de eerste test start, kandidaten worden automatisch aan nieuwe testen gekoppeld.
Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.
+
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.
+ {{ 'Are you sure you want to delete this candidate? All their answers will be lost.'|trans }}
+
+
+
+
+
+
{% else %}
{{ 'No candidates'|trans }}
{% endfor %}
diff --git a/tests/Controller/Backoffice/SeasonControllerTest.php b/tests/Controller/Backoffice/SeasonControllerTest.php
new file mode 100644
index 0000000..e0fae1a
--- /dev/null
+++ b/tests/Controller/Backoffice/SeasonControllerTest.php
@@ -0,0 +1,120 @@
+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);
+ }
+
+ 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);
+ }
+}
diff --git a/translations/messages+intl-icu.nl.xliff b/translations/messages+intl-icu.nl.xliff
index b077c92..76eadf0 100644
--- a/translations/messages+intl-icu.nl.xliff
+++ b/translations/messages+intl-icu.nl.xliff
@@ -5,6 +5,10 @@
+
+ A candidate with this name already exists in this season
+ Er bestaat al een kandidaat met deze naam in dit seizoen
+ A label with a similar name already existsEr bestaat al een label met deze naam
@@ -101,6 +105,10 @@
Are you sure you want to clear all the results? This will also delete all the eliminations.Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.
+
+ Are you sure you want to delete this candidate? All their answers will be lost.
+ Weet je zeker dat je deze kandidaat wilt verwijderen? Alle gegeven antwoorden gaan dan verloren.
+ Are you sure you want to delete this question from the question bank?Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?
@@ -145,10 +153,18 @@
Candidate answers savedKandidaatantwoorden opgeslagen
+
+ Candidate deleted
+ Kandidaat verwijderd
+ Candidate not foundKandidaat niet gevonden
+
+ Candidate renamed
+ Kandidaat hernoemd
+ Candidate status updatedKandidaatstatus bijgewerkt
@@ -681,6 +697,14 @@
Remove labelLabel verwijderen
+
+ Rename
+ Hernoemen
+
+
+ Rename candidate
+ Kandidaat hernoemen
+ Repeat PasswordHerhaal wachtwoord
@@ -769,6 +793,18 @@
Sync latest changes to this quizLaatste wijzigingen synchroniseren naar deze quiz
+
+ The candidate name must be between 1 and 16 characters
+ De naam van de kandidaat moet tussen de 1 en 16 tekens zijn
+
+
+ The confirmation email could not be sent. Please try again later.
+ De bevestigingsmail kon niet worden verzonden. Probeer het later opnieuw.
+
+
+ The confirmation email could not be sent. Please use the resend button to try again.
+ De bevestigingsmail kon niet worden verzonden. Gebruik de knop om het opnieuw te proberen.
+ The password fields must match.De wachtwoorden moeten overeen komen.
@@ -901,18 +937,14 @@
Your dataJe gegevens
+
+ Your email address has been changed.
+ Je e-mailadres is gewijzigd.
+ Your email address has been changed. Please check your inbox to confirm it.Je e-mailadres is gewijzigd. Check je inbox om het te bevestigen.
-
- The confirmation email could not be sent. Please use the resend button to try again.
- De bevestigingsmail kon niet worden verzonden. Gebruik de knop om het opnieuw te proberen.
-
-
- The confirmation email could not be sent. Please try again later.
- De bevestigingsmail kon niet worden verzonden. Probeer het later opnieuw.
- Your email address has been verified.Je e-mailadres is geverifieerd.