diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php
index 22fc715..ab63bbb 100644
--- a/src/Controller/Backoffice/SeasonController.php
+++ b/src/Controller/Backoffice/SeasonController.php
@@ -15,6 +15,7 @@ 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;
@@ -28,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;
@@ -39,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')]
@@ -143,6 +146,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
index 12922c3..6982157 100644
--- a/tests/Controller/Backoffice/SeasonControllerTest.php
+++ b/tests/Controller/Backoffice/SeasonControllerTest.php
@@ -10,6 +10,7 @@ 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;
@@ -67,4 +68,92 @@ final class SeasonControllerTest extends WebTestCase
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);
+ }
}
diff --git a/translations/messages+intl-icu.nl.xliff b/translations/messages+intl-icu.nl.xliff
index 2cb4957..73b6210 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
@@ -697,6 +713,14 @@
Remove labelLabel verwijderen
+
+ Rename
+ Hernoemen
+
+
+ Rename candidate
+ Kandidaat hernoemen
+ Repeat PasswordHerhaal wachtwoord
@@ -793,6 +817,10 @@
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.