diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18d7428..917862d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,15 @@ jobs: id: rector continue-on-error: true run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github + - name: Translations + id: translations + continue-on-error: true + run: | + docker compose exec -T php bin/console translation:extract --force --format=xliff --sort=asc nl + if ! git diff --exit-code -- translations; then + echo "::error::Translations are out of date. Run 'just translations' and commit the changes." + exit 1 + fi - name: Check HTTP reachability run: curl -v --fail-with-body http://localhost - name: Assert all checks passed @@ -113,6 +122,7 @@ jobs: check "Twig Coding Style" "${{ steps.twig_cs.outcome }}" check "PHPStan" "${{ steps.phpstan.outcome }}" check "Rector" "${{ steps.rector.outcome }}" + check "Translations" "${{ steps.translations.outcome }}" exit $failed tests: diff --git a/CLAUDE.md b/CLAUDE.md index ced2ff6..e66b603 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -306,6 +306,7 @@ GitHub Actions workflow (`.github/workflows/ci.yml`): - Twig-CS-Fixer style check - PHPStan static analysis - Rector dry-run + - Translation extraction check (fails if `translation:extract` produces uncommitted changes) 3. **Integration Tests**: - Docker image build and start services - Database creation and migration diff --git a/Justfile b/Justfile index dfd5d6e..d39e12c 100644 --- a/Justfile +++ b/Justfile @@ -61,9 +61,20 @@ up *args: init down *args: docker compose down --remove-orphans {{ args }} -# Show the host ports assigned to this worktree (see `just init`) +# Show the URLs (and DB connection) assigned to this worktree (see `just init`) ports: - @cat .env.local 2>/dev/null || echo "No .env.local yet, run 'just up' or 'just init' first." + #!/usr/bin/env bash + set -euo pipefail + if [ ! -f .env.local ]; then + echo "No .env.local yet, run 'just up' or 'just init' first." + exit 0 + fi + set -a + source .env.local + set +a + echo "Tvdt: https://localhost:${HTTPS_PORT}" + echo "Mailpit: http://localhost:${MAILPIT_PORT}" + echo "Spotlight: http://localhost:${SPOTLIGHT_PORT}" stop: docker compose stop diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index 6d54259..be1a436 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -25,6 +25,7 @@ use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\Season; use Tvdt\Enum\FlashType; use Tvdt\Exception\ErrorClearingQuizException; +use Tvdt\Repository\GivenAnswerRepository; use Tvdt\Repository\QuizCandidateRepository; use Tvdt\Repository\QuizRepository; use Tvdt\Security\Voter\SeasonVoter; @@ -37,6 +38,7 @@ class QuizController extends AbstractController private readonly QuizRepository $quizRepository, private readonly TranslatorInterface $translator, private readonly QuizCandidateRepository $quizCandidateRepository, + private readonly GivenAnswerRepository $givenAnswerRepository, private readonly EntityManagerInterface $em, ) {} @@ -397,6 +399,26 @@ class QuizController extends AbstractController return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); } + #[IsCsrfTokenValid('reset_candidate_progress')] + #[IsGranted(SeasonVoter::EDIT, subject: 'quiz')] + #[Route( + '/backoffice/quiz/{quiz}/candidate/{candidate}/reset', + name: 'tvdt_backoffice_reset_candidate_progress', + requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID], + methods: ['POST'], + )] + public function resetCandidateProgress(Quiz $quiz, Candidate $candidate): RedirectResponse + { + $this->em->wrapInTransaction(function () use ($quiz, $candidate): void { + $this->givenAnswerRepository->deleteAllForCandidateInQuiz($quiz, $candidate); + $this->quizCandidateRepository->resetProgressForCandidate($quiz, $candidate); + }); + + $this->addFlash(FlashType::Success, $this->translator->trans('Candidate progress reset')); + + return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); + } + /** * Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates. * diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php index ab63bbb..ca0e90c 100644 --- a/src/Controller/Backoffice/SeasonController.php +++ b/src/Controller/Backoffice/SeasonController.php @@ -129,6 +129,8 @@ class SeasonController extends AbstractController )] public function addCandidates(Season $season, Request $request): Response { + $isTurboFrame = $request->headers->has('Turbo-Frame'); + $form = $this->createForm(AddCandidatesFormType::class); $form->handleRequest($request); @@ -140,10 +142,18 @@ class SeasonController extends AbstractController $this->em->flush(); - return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]); + if ($isTurboFrame) { + return new Response(''); + } + + return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]); } - return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]); + $template = $isTurboFrame + ? 'backoffice/season/_add_candidates_frame.html.twig' + : 'backoffice/season_add_candidates.html.twig'; + + return $this->render($template, ['form' => $form, 'season' => $season]); } #[IsCsrfTokenValid('rename_candidate')] diff --git a/src/Form/AddCandidatesFormType.php b/src/Form/AddCandidatesFormType.php index a75823d..0aff05d 100644 --- a/src/Form/AddCandidatesFormType.php +++ b/src/Form/AddCandidatesFormType.php @@ -19,7 +19,9 @@ class AddCandidatesFormType extends AbstractType { $builder ->add('candidates', TextareaType::class, [ - 'label' => $this->translator->trans('Candidates'), 'translation_domain' => false, + 'label' => $this->translator->trans('Candidates'), + 'help' => $this->translator->trans('One candidate per line'), + 'translation_domain' => false, ]) ; } diff --git a/src/Repository/GivenAnswerRepository.php b/src/Repository/GivenAnswerRepository.php index 331eec8..fbd51e5 100644 --- a/src/Repository/GivenAnswerRepository.php +++ b/src/Repository/GivenAnswerRepository.php @@ -6,7 +6,9 @@ namespace Tvdt\Repository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +use Tvdt\Entity\Candidate; use Tvdt\Entity\GivenAnswer; +use Tvdt\Entity\Quiz; /** @extends ServiceEntityRepository */ class GivenAnswerRepository extends ServiceEntityRepository @@ -15,4 +17,15 @@ class GivenAnswerRepository extends ServiceEntityRepository { parent::__construct($registry, GivenAnswer::class); } + + public function deleteAllForCandidateInQuiz(Quiz $quiz, Candidate $candidate): void + { + $givenAnswers = $this->findBy(['quiz' => $quiz, 'candidate' => $candidate]); + + foreach ($givenAnswers as $givenAnswer) { + $this->getEntityManager()->remove($givenAnswer); + } + + $this->getEntityManager()->flush(); + } } diff --git a/src/Repository/QuizCandidateRepository.php b/src/Repository/QuizCandidateRepository.php index 931ef82..d247031 100644 --- a/src/Repository/QuizCandidateRepository.php +++ b/src/Repository/QuizCandidateRepository.php @@ -68,4 +68,15 @@ class QuizCandidateRepository extends ServiceEntityRepository $quizCandidate->penaltySeconds = $penalty; $this->getEntityManager()->flush(); } + + public function resetProgressForCandidate(Quiz $quiz, Candidate $candidate): void + { + $quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]); + if (!$quizCandidate instanceof QuizCandidate) { + return; + } + + $quizCandidate->started = null; + $this->getEntityManager()->flush(); + } } diff --git a/templates/backoffice/quiz/tab_candidates_list.html.twig b/templates/backoffice/quiz/tab_candidates_list.html.twig index 3db6809..1aae8b5 100644 --- a/templates/backoffice/quiz/tab_candidates_list.html.twig +++ b/templates/backoffice/quiz/tab_candidates_list.html.twig @@ -37,7 +37,7 @@ {% endif %} -
+
+ {% if quizCandidate and quizCandidate.started %} +
+ + +
+ {% endif %} {% endfor %} diff --git a/templates/backoffice/season/_add_candidates_frame.html.twig b/templates/backoffice/season/_add_candidates_frame.html.twig new file mode 100644 index 0000000..ab5898b --- /dev/null +++ b/templates/backoffice/season/_add_candidates_frame.html.twig @@ -0,0 +1,11 @@ + + {{ form_start(form, {attr: {novalidate: 'novalidate'}}) }} + + + {{ form_end(form) }} + diff --git a/templates/backoffice/season/tab_candidates.html.twig b/templates/backoffice/season/tab_candidates.html.twig index 33fab63..6621621 100644 --- a/templates/backoffice/season/tab_candidates.html.twig +++ b/templates/backoffice/season/tab_candidates.html.twig @@ -1,8 +1,10 @@ -
+
- {{ 'Add Candidate'|trans }} +
    {% for candidate in season.candidates %} @@ -17,7 +19,9 @@ title="{{ 'Delete'|trans }}">
-