From e0350c8c31ea18828052ac7b47b9740b848ee943 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Mon, 19 May 2025 22:29:56 +0200 Subject: [PATCH] WIP --- .env.dev | 1 + .github/workflows/ci.yml | 6 +- Dockerfile | 2 +- compose.yaml | 3 - migrations/Version20250504101440.php | 41 ++++++++++++ rector.php | 1 - src/Command/ClaimSeasonCommand.php | 67 +++++++++++++++++++ src/Command/MakeAdminCommand.php | 2 +- src/Controller/BackofficeController.php | 8 ++- src/Controller/EliminationController.php | 54 +++++++++++++++ .../PrepareEliminationController.php | 8 ++- src/Entity/Answer.php | 2 +- src/Entity/Candidate.php | 1 + src/Entity/Elimination.php | 16 +++++ src/Entity/Quiz.php | 19 ++++++ src/Entity/Season.php | 1 + src/Entity/User.php | 3 +- src/Security/Voter/SeasonVoter.php | 19 ++---- src/Service/QuizSpreadsheetService.php | 1 + templates/backoffice/nav.html.twig | 2 +- templates/backoffice/quiz.html.twig | 14 +++- templates/backoffice/quiz_add.html.twig | 2 +- templates/elimination/candidate.html.twig | 3 + templates/elimination/index.html.twig | 20 ++++++ templates/prepare_elimination/index.html.twig | 26 +++++++ translations/messages+intl-icu.nl.yaml | 7 +- 26 files changed, 295 insertions(+), 34 deletions(-) create mode 100644 migrations/Version20250504101440.php create mode 100644 src/Command/ClaimSeasonCommand.php create mode 100644 src/Controller/EliminationController.php create mode 100644 templates/elimination/candidate.html.twig create mode 100644 templates/elimination/index.html.twig create mode 100644 templates/prepare_elimination/index.html.twig diff --git a/.env.dev b/.env.dev index 90c9484..7c6432a 100644 --- a/.env.dev +++ b/.env.dev @@ -2,3 +2,4 @@ ###> symfony/framework-bundle ### APP_SECRET=e26b9552d9e7f969b160373effaa7690 ###< symfony/framework-bundle ### +MAILER_SENDER=info@tijdvoordetest.nl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2032b2c..96dd4aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,12 +36,12 @@ jobs: run: docker compose up --wait --no-build - name: Coding Style run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none + - name: Twig Coding Style + run: docker compose exec -T php vendor/bin/twig-cs-fixer check - name: Check HTTP reachability run: curl -v --fail-with-body http://localhost - - name: Check HTTPS reachability - if: false # Remove this line when the homepage will be configured, or change the path to check - run: curl -vk --fail-with-body https://localhost - name: Check Mercure reachability + if: false run: curl -vkI --fail-with-body https://localhost/.well-known/mercure?topic=test - name: Create test database run: docker compose exec -T php bin/console -e test doctrine:database:create diff --git a/Dockerfile b/Dockerfile index 06261de..41c34c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN set -eux; \ zip \ uuid \ gd \ - excimer \ + excimer-1.2.3 \ ; # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser diff --git a/compose.yaml b/compose.yaml index 310fd29..4e82753 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,9 +12,6 @@ services: MERCURE_URL: ${CADDY_MERCURE_URL:-http://php/.well-known/mercure} MERCURE_PUBLIC_URL: ${CADDY_MERCURE_PUBLIC_URL:-https://${SERVER_NAME:-localhost}/.well-known/mercure} MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} - # The two next lines can be removed after initial installation - SYMFONY_VERSION: ${SYMFONY_VERSION:-} - STABILITY: ${STABILITY:-stable} volumes: - caddy_data:/data - caddy_config:/config diff --git a/migrations/Version20250504101440.php b/migrations/Version20250504101440.php new file mode 100644 index 0000000..0098984 --- /dev/null +++ b/migrations/Version20250504101440.php @@ -0,0 +1,41 @@ +addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_C8B28E445E237E064EC001D1 ON candidate (name, season_id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_A412FA925E237E064EC001D1 ON quiz (name, season_id) + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + DROP INDEX UNIQ_A412FA925E237E064EC001D1 + SQL); + $this->addSql(<<<'SQL' + DROP INDEX UNIQ_C8B28E445E237E064EC001D1 + SQL); + } +} diff --git a/rector.php b/rector.php index a6575ac..6116cfd 100644 --- a/rector.php +++ b/rector.php @@ -30,5 +30,4 @@ return RectorConfig::configure() ) ->withAttributesSets(all: true) ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true) - ->withAttributesSets() ; diff --git a/src/Command/ClaimSeasonCommand.php b/src/Command/ClaimSeasonCommand.php new file mode 100644 index 0000000..60ec3a7 --- /dev/null +++ b/src/Command/ClaimSeasonCommand.php @@ -0,0 +1,67 @@ +addArgument('email', InputArgument::REQUIRED, 'The email of the user to make admin') + ->addArgument('season', InputArgument::REQUIRED, 'The season to claim') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $email = $input->getArgument('email'); + $seasonCode = $input->getArgument('season'); + + try { + $season = $this->seasonRepository->findOneBy(['seasonCode' => $seasonCode]); + if (null === $season) { + throw new \InvalidArgumentException('Season not found'); + } + + $user = $this->userRepository->findOneBy(['email' => $email]); + if (null === $user) { + throw new \InvalidArgumentException('User not found'); + } + + $season->addOwner($user); + + $this->entityManager->flush(); + } catch (\InvalidArgumentException $invalidArgumentException) { + $io->error($invalidArgumentException->getMessage()); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/MakeAdminCommand.php b/src/Command/MakeAdminCommand.php index 24ad15f..2bd81e1 100644 --- a/src/Command/MakeAdminCommand.php +++ b/src/Command/MakeAdminCommand.php @@ -26,7 +26,7 @@ class MakeAdminCommand extends Command protected function configure(): void { $this - ->addArgument('email', InputArgument::OPTIONAL, 'The email of the user to make admin') + ->addArgument('email', InputArgument::REQUIRED, 'The email of the user to make admin') ; } diff --git a/src/Controller/BackofficeController.php b/src/Controller/BackofficeController.php index a85e656..370c0da 100644 --- a/src/Controller/BackofficeController.php +++ b/src/Controller/BackofficeController.php @@ -87,6 +87,7 @@ final class BackofficeController extends AbstractController } #[Route('/backoffice/{seasonCode}/{quiz}', name: 'app_backoffice_quiz')] + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] public function quiz(Season $season, Quiz $quiz): Response { return $this->render('backoffice/quiz.html.twig', [ @@ -98,7 +99,7 @@ final class BackofficeController extends AbstractController #[Route('/backoffice/{seasonCode}/{quiz}/enable', name: 'app_backoffice_enable')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')] - public function enableQuiz(Season $season, Quiz $quiz, EntityManagerInterface $em): Response + public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): Response { $season->setActiveQuiz($quiz); $em->flush(); @@ -107,6 +108,7 @@ final class BackofficeController extends AbstractController } #[Route('/backoffice/{seasonCode}/add_candidate', name: 'app_backoffice_add_candidates', priority: 10)] + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] public function addCandidates(Season $season, Request $request, EntityManagerInterface $em): Response { $form = $this->createForm(AddCandidatesFormType::class); @@ -114,9 +116,10 @@ final class BackofficeController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $candidates = $form->get('candidates')->getData(); - foreach (explode("\r\n", $candidates) as $candidate) { + foreach (explode("\r\n", (string) $candidates) as $candidate) { $season->addCandidate(new Candidate($candidate)); } + $em->flush(); return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]); @@ -126,6 +129,7 @@ final class BackofficeController extends AbstractController } #[Route('/backoffice/{seasonCode}/add', name: 'app_backoffice_quiz_add', priority: 10)] + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] public function addQuiz(Request $request, Season $season, QuizSpreadsheetService $quizSpreadsheet, EntityManagerInterface $em): Response { $quiz = new Quiz(); diff --git a/src/Controller/EliminationController.php b/src/Controller/EliminationController.php new file mode 100644 index 0000000..77559d0 --- /dev/null +++ b/src/Controller/EliminationController.php @@ -0,0 +1,54 @@ +render('elimination/index.html.twig', [ + 'controller_name' => 'EliminationController', + ]); + } + + #[Route('/elimination/{seasonCode}/{candidateHash}', name: 'app_elimination_cadidate')] + #[IsGranted(SeasonVoter::ELIMINATION, 'season')] + public function candidateScreen(Season $season, string $candidateHash, CandidateRepository $candidateRepository): Response + { + $candidate = $candidateRepository->getCandidateByHash($season, $candidateHash); + if (!$candidate instanceof Candidate) { + $this->addFlash(FlashType::Warning, + t('Cound not find candidate with name %name%', ['%name%' => Base64::base64UrlDecode($candidateHash)])->trans($this->translator) + ); + throw new \InvalidArgumentException('Candidate not found'); + } + + return $this->render('elimination/candidate.html.twig', [ + 'season' => $season, + 'candidate' => $candidate, + ]); + } +} diff --git a/src/Controller/PrepareEliminationController.php b/src/Controller/PrepareEliminationController.php index ecaedf4..1e68c03 100644 --- a/src/Controller/PrepareEliminationController.php +++ b/src/Controller/PrepareEliminationController.php @@ -4,17 +4,21 @@ declare(strict_types=1); namespace App\Controller; +use App\Entity\Quiz; +use App\Entity\Season; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; final class PrepareEliminationController extends AbstractController { - #[Route('/backoffice/elimination/prepare', name: 'app_prepare_elimination')] - public function index(): Response + #[Route('/backoffice/elimination/{seasonCode}/{quiz}/prepare', name: 'app_prepare_elimination')] + public function index(Season $season, Quiz $quiz): Response { return $this->render('prepare_elimination/index.html.twig', [ 'controller_name' => 'PrepareEliminationController', + 'season' => $season, + 'quiz' => $quiz, ]); } } diff --git a/src/Entity/Answer.php b/src/Entity/Answer.php index 119b1ee..ffb217b 100644 --- a/src/Entity/Answer.php +++ b/src/Entity/Answer.php @@ -23,7 +23,7 @@ class Answer private Uuid $id; #[ORM\Column(type: Types::SMALLINT, options: ['default' => 0])] - private int $ordering; + private int $ordering = 0; #[ORM\ManyToOne(inversedBy: 'answers')] #[ORM\JoinColumn(nullable: false)] diff --git a/src/Entity/Candidate.php b/src/Entity/Candidate.php index 38a03e5..ee6254f 100644 --- a/src/Entity/Candidate.php +++ b/src/Entity/Candidate.php @@ -14,6 +14,7 @@ use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: CandidateRepository::class)] +#[ORM\UniqueConstraint(fields: ['name', 'season'])] class Candidate { #[ORM\Id] diff --git a/src/Entity/Elimination.php b/src/Entity/Elimination.php index 8624b74..7f5b271 100644 --- a/src/Entity/Elimination.php +++ b/src/Entity/Elimination.php @@ -20,6 +20,10 @@ class Elimination #[ORM\CustomIdGenerator(class: UuidGenerator::class)] private Uuid $id; + #[ORM\ManyToOne(inversedBy: 'eliminations')] + #[ORM\JoinColumn(nullable: false)] + private Quiz $quiz; + /** @var array */ #[ORM\Column(type: Types::JSON)] private array $data = []; @@ -42,4 +46,16 @@ class Elimination return $this; } + + public function setQuiz(Quiz $quiz): self + { + $this->quiz = $quiz; + + return $this; + } + + public function getQuiz(): Quiz + { + return $this->quiz; + } } diff --git a/src/Entity/Quiz.php b/src/Entity/Quiz.php index a238039..547406b 100644 --- a/src/Entity/Quiz.php +++ b/src/Entity/Quiz.php @@ -13,6 +13,7 @@ use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: QuizRepository::class)] +#[ORM\UniqueConstraint(fields: ['name', 'season'])] class Quiz { #[ORM\Id] @@ -40,10 +41,15 @@ class Quiz #[ORM\Column(nullable: true)] private ?int $dropouts = null; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)] + private Collection $eliminations; + public function __construct() { $this->questions = new ArrayCollection(); $this->corrections = new ArrayCollection(); + $this->eliminations = new ArrayCollection(); } public function getId(): ?Uuid @@ -118,4 +124,17 @@ class Quiz return $this; } + + /** @return Collection */ + public function getEliminations(): Collection + { + return $this->eliminations; + } + + public function addElimination(Elimination $elimination): self + { + $this->eliminations->add($elimination); + + return $this; + } } diff --git a/src/Entity/Season.php b/src/Entity/Season.php index 00cf91e..97d65b9 100644 --- a/src/Entity/Season.php +++ b/src/Entity/Season.php @@ -35,6 +35,7 @@ class Season /** @var Collection */ #[ORM\OneToMany(targetEntity: Candidate::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)] + #[ORM\OrderBy(['name' => 'ASC'])] private Collection $candidates; /** @var Collection */ diff --git a/src/Entity/User.php b/src/Entity/User.php index 097edd7..5ca9d4b 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -7,6 +7,7 @@ namespace App\Entity; use App\Repository\UserRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator; use Symfony\Bridge\Doctrine\Types\UuidType; @@ -31,7 +32,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private string $email; /** @var list The user roles */ - #[ORM\Column] + #[ORM\Column(type: Types::JSON)] private array $roles = []; /** @var string The hashed password */ diff --git a/src/Security/Voter/SeasonVoter.php b/src/Security/Voter/SeasonVoter.php index 71f0f6e..31d8541 100644 --- a/src/Security/Voter/SeasonVoter.php +++ b/src/Security/Voter/SeasonVoter.php @@ -14,11 +14,13 @@ final class SeasonVoter extends Voter { public const string EDIT = 'SEASON_EDIT'; + public const string ELIMINATION = 'SEASON_ELIMINATION'; + public const string DELETE = 'SEASON_DELETE'; protected function supports(string $attribute, mixed $subject): bool { - return \in_array($attribute, [self::EDIT, self::DELETE], true) + return \in_array($attribute, [self::EDIT, self::DELETE, self::ELIMINATION], true) && $subject instanceof Season; } @@ -34,16 +36,9 @@ final class SeasonVoter extends Voter return true; } - switch ($attribute) { - case self::EDIT: - case self::DELETE: - if ($subject->isOwner($user)) { - return true; - } - - break; - } - - return false; + return match ($attribute) { + self::EDIT, self::DELETE, self::ELIMINATION => $subject->isOwner($user), + default => false, + }; } } diff --git a/src/Service/QuizSpreadsheetService.php b/src/Service/QuizSpreadsheetService.php index 19cd5dd..d0a884b 100644 --- a/src/Service/QuizSpreadsheetService.php +++ b/src/Service/QuizSpreadsheetService.php @@ -94,6 +94,7 @@ class QuizSpreadsheetService if (1 === $answerCounter) { $errors[] = \sprintf('Question %d has no answers', $answerCounter); } + break; } diff --git a/templates/backoffice/nav.html.twig b/templates/backoffice/nav.html.twig index 4424449..45bf351 100644 --- a/templates/backoffice/nav.html.twig +++ b/templates/backoffice/nav.html.twig @@ -1,6 +1,6 @@