diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 5c91296..3fe6a75 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -6,6 +6,11 @@ true org.postgresql.Driver jdbc:postgresql://localhost:5432/app + + + + + $ProjectFileDir$ diff --git a/assets/backoffice.js b/assets/backoffice.js index 0729569..f525188 100644 --- a/assets/backoffice.js +++ b/assets/backoffice.js @@ -1,5 +1,3 @@ -import * as bootstrap from 'bootstrap' -import './bootstrap.js'; -import 'bootstrap/dist/css/bootstrap.min.css' - +import './stimulus.js'; +import './bootstrap.js' import './styles/backoffice.scss'; diff --git a/assets/bootstrap.js b/assets/bootstrap.js index d4e50c9..86fcee5 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -1,5 +1,2 @@ -import { startStimulusApp } from '@symfony/stimulus-bundle'; - -const app = startStimulusApp(); -// register any custom, 3rd party controllers here -// app.register('some_controller_name', SomeImportedController); +import * as bootstrap from 'bootstrap' +import 'bootstrap/dist/css/bootstrap.min.css' diff --git a/assets/quiz.js b/assets/quiz.js index b9160a7..1ba33e6 100644 --- a/assets/quiz.js +++ b/assets/quiz.js @@ -1,6 +1,4 @@ -import './bootstrap.js'; -import 'bootstrap/dist/css/bootstrap.min.css' -import * as bootstrap from 'bootstrap' - -import './styles/app.scss' +import './stimulus.js'; +import './bootstrap.js' +import './styles/quiz.scss' diff --git a/assets/stimulus.js b/assets/stimulus.js new file mode 100644 index 0000000..b22f20c --- /dev/null +++ b/assets/stimulus.js @@ -0,0 +1,3 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); diff --git a/assets/stimulus_bootstrap.js b/assets/stimulus_bootstrap.js deleted file mode 100644 index 2689398..0000000 --- a/assets/stimulus_bootstrap.js +++ /dev/null @@ -1,2 +0,0 @@ -// register any custom, 3rd party controllers here -// app.register('some_controller_name', SomeImportedController); diff --git a/assets/styles/app.scss b/assets/styles/quiz.scss similarity index 100% rename from assets/styles/app.scss rename to assets/styles/quiz.scss diff --git a/compose.build.yaml b/compose.build.yaml index c595020..78ebb79 100644 --- a/compose.build.yaml +++ b/compose.build.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-go/master/schema/compose-spec.json services: php: build: diff --git a/compose.override.yaml b/compose.override.yaml index 65cb06d..df3e622 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-go/master/schema/compose-spec.json # Development environment override services: php: diff --git a/compose.prod.yaml b/compose.prod.yaml index 1907314..9dd8f5a 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-go/master/schema/compose-spec.json # Production environment override services: php: diff --git a/compose.yaml b/compose.yaml index 4e82753..a9b8fce 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-go/master/schema/compose-spec.json services: php: image: ${IMAGES_PREFIX:-}app-php diff --git a/config/packages/maker.yaml b/config/packages/maker.yaml new file mode 100644 index 0000000..aebcc27 --- /dev/null +++ b/config/packages/maker.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../vendor/symfony/dependency-injection/Loader/schema/services.schema.json +when@dev: + maker: + root_namespace: 'Tvdt' + generate_final_classes: true + generate_final_entities: false diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 5902804..772e1e9 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -5,12 +5,10 @@ security: # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - # used to reload user from session & other features (e.g. switch_user) tvdt_user_provider: entity: class: Tvdt\Entity\User property: email - # used to reload user from session & other features (e.g. switch_user) firewalls: dev: # Ensure dev tools and static assets are always allowed @@ -26,15 +24,7 @@ security: default_target_path: tvdt_backoffice_index logout: path: tvdt_login_logout - # where to redirect after logout - # target: tvdt_any_route - # activate different ways to authenticate - # https://symfony.com/doc/current/security.html#the-firewall - - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true - access_control: - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/backoffice, roles: ROLE_USER } diff --git a/config/packages/sentry.yaml b/config/packages/sentry.yaml index dea194c..aefb61a 100644 --- a/config/packages/sentry.yaml +++ b/config/packages/sentry.yaml @@ -8,6 +8,7 @@ when@prod: ignore_exceptions: - 'Symfony\Component\ErrorHandler\Error\FatalError' - 'Symfony\Component\Debug\Exception\FatalErrorException' + - 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException' # # # If you are using Monolog, you also need this additional configuration to log the errors correctly: # # https://docs.sentry.io/platforms/php/guides/symfony/integrations/monolog/ diff --git a/importmap.php b/importmap.php index 56f5dc6..0398a30 100644 --- a/importmap.php +++ b/importmap.php @@ -22,23 +22,23 @@ return [ 'path' => './assets/backoffice.js', 'entrypoint' => true, ], + '@symfony/stimulus-bundle' => [ + 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', + ], 'bootstrap' => [ - 'version' => '5.3.6', + 'version' => '5.3.8', ], '@popperjs/core' => [ 'version' => '2.11.8', ], 'bootstrap/dist/css/bootstrap.min.css' => [ - 'version' => '5.3.6', + 'version' => '5.3.8', 'type' => 'css', ], '@hotwired/stimulus' => [ 'version' => '3.2.2', ], - '@symfony/stimulus-bundle' => [ - 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', - ], '@hotwired/turbo' => [ - 'version' => '7.3.0', + 'version' => '8.0.23', ], ]; diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index c54c872..6261893 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -16,6 +16,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Contracts\Translation\TranslatorInterface; use Tvdt\Controller\AbstractController; use Tvdt\Entity\Candidate; +use Tvdt\Entity\Question; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; use Tvdt\Exception\ErrorClearingQuizException; @@ -40,11 +41,122 @@ class QuizController extends AbstractController requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], )] public function index(Season $season, Quiz $quiz): Response + { + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $season->seasonCode, 'quiz' => $quiz->id]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/overview', + name: 'tvdt_backoffice_quiz_overview', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + )] + public function overview(Season $season, Quiz $quiz): Response + { + $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); + + return $this->render('backoffice/quiz.html.twig', [ + 'season' => $season, + 'quiz' => $fetchedQuiz, + 'activeTab' => 'overview', + 'template' => 'backoffice/quiz/tab_overview.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/result', + name: 'tvdt_backoffice_quiz_result', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + )] + public function result(Season $season, Quiz $quiz): Response + { + $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); + + return $this->render('backoffice/quiz.html.twig', [ + 'season' => $season, + 'quiz' => $fetchedQuiz, + 'result' => $this->quizRepository->getScores($quiz), + 'activeTab' => 'result', + 'template' => 'backoffice/quiz/tab_result.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates', + name: 'tvdt_backoffice_quiz_candidates', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + )] + public function candidates(Season $season, Quiz $quiz): Response + { + $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); + assert($fetchedQuiz->questions->count() > 0); + $firstQuestion = $fetchedQuiz->questions->first(); + + return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [ + 'seasonCode' => $season->seasonCode, + 'quiz' => $quiz->id, + 'question' => $firstQuestion->id, + ]); + } + + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}', + name: 'tvdt_backoffice_quiz_candidates_question', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + methods: ['GET'] + )] + public function candidates_question(Season $season, Quiz $quiz, Question $question): Response { return $this->render('backoffice/quiz.html.twig', [ 'season' => $season, 'quiz' => $quiz, - 'result' => $this->quizRepository->getScores($quiz), + 'question' => $question, + 'candidates' => $season->candidates, + 'activeTab' => 'candidates', + 'template' => 'backoffice/quiz/tab_candidates.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates/{question}', + name: 'tvdt_backoffice_quiz_candidates_question_save', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + methods: ['POST'] + )] + public function saveCandidateAnswers(Season $season, Quiz $quiz, Question $question, Request $request, EntityManagerInterface $em): RedirectResponse + { + $candidateAnswers = $request->request->all('candidate_answer'); + + // Clear existing candidate-answer associations for this question + foreach ($question->answers as $answer) { + $answer->candidates->clear(); + } + + // Add new associations + foreach ($candidateAnswers as $candidateId => $answerIds) { + $candidate = $em->getRepository(Candidate::class)->find($candidateId); + + foreach ((array) $answerIds as $answerId) { + $answer = $em->getRepository(\Tvdt\Entity\Answer::class)->find($answerId); + if ($answer && $candidate) { + $answer->addCandidate($candidate); + } + } + } + + $em->flush(); + + $this->addFlash('success', $this->translator->trans('Candidate answers saved')); + + return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_question', [ + 'seasonCode' => $season->seasonCode, + 'quiz' => $quiz->id, + 'question' => $question->id, ]); } diff --git a/src/Entity/Answer.php b/src/Entity/Answer.php index e45027c..3f9bb64 100644 --- a/src/Entity/Answer.php +++ b/src/Entity/Answer.php @@ -57,4 +57,10 @@ class Answer { $this->candidates->removeElement($candidate); } + + public function __toString(): string + { + return $this->text; + } + } diff --git a/src/Entity/Question.php b/src/Entity/Question.php index 62b5ef2..eefd3f9 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -70,6 +70,71 @@ class Question return 'This question has multiple correct answers'; } + // Check if any answer in the entire quiz has candidate relations + $hasCandidateRelations = false; + foreach ($this->quiz->questions as $quizQuestion) { + foreach ($quizQuestion->answers as $answer) { + if ($answer->candidates->count() > 0) { + $hasCandidateRelations = true; + break 2; + } + } + } + + // Only validate candidate-answer relations if at least one exists in the quiz + if ($hasCandidateRelations) { + $seasonCandidates = $this->quiz->season->candidates; + $candidateCounts = []; + + // Count how many times each candidate appears in answers + foreach ($this->answers as $answer) { + foreach ($answer->candidates as $candidate) { + $candidateId = $candidate->id->toString(); + if (!isset($candidateCounts[$candidateId])) { + $candidateCounts[$candidateId] = ['name' => $candidate->name, 'count' => 0]; + } + ++$candidateCounts[$candidateId]['count']; + } + } + + // Check for missing and duplicate candidates + $missing = []; + $duplicates = []; + + foreach ($seasonCandidates as $candidate) { + $candidateId = $candidate->id->toString(); + $count = $candidateCounts[$candidateId]['count'] ?? 0; + + if (0 === $count) { + $missing[] = $candidate->name; + } elseif ($count > 1) { + $duplicates[] = $candidate->name; + } + } + + if (!empty($missing) || !empty($duplicates)) { + $errors = []; + if (!empty($missing)) { + // If all candidates are missing, show a special message + if (\count($missing) === $seasonCandidates->count()) { + $errors[] = 'No candidates assigned to this question'; + } else { + $errors[] = 'Missing candidates: '.implode(', ', $missing); + } + } + if (!empty($duplicates)) { + $errors[] = 'Duplicate candidates: '.implode(', ', $duplicates); + } + + return implode('. ', $errors); + } + } + return null; } + + public function __toString(): string + { + return $this->question; + } } diff --git a/src/Repository/QuizRepository.php b/src/Repository/QuizRepository.php index 9c4a4e9..4fa3ae0 100644 --- a/src/Repository/QuizRepository.php +++ b/src/Repository/QuizRepository.php @@ -9,6 +9,7 @@ use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; use Safe\DateTimeImmutable; use Safe\Exceptions\DatetimeException; +use Symfony\Component\Uid\Uuid; use Tvdt\Dto\Result; use Tvdt\Entity\Quiz; use Tvdt\Exception\ErrorClearingQuizException; @@ -105,4 +106,14 @@ class QuizRepository extends ServiceEntityRepository score: $row['score'], ), $result); } + + public function fetchWithQuestions(Uuid $id): Quiz + { + return $this->getEntityManager()->createQuery(<<setParameter('id', $id)->getSingleResult(); + } } diff --git a/templates/backoffice/quiz.html.twig b/templates/backoffice/quiz.html.twig index 4e7cc2e..8922410 100644 --- a/templates/backoffice/quiz.html.twig +++ b/templates/backoffice/quiz.html.twig @@ -1,182 +1,25 @@ {% extends 'backoffice/base.html.twig' %} -{% block title %}{{ parent() }}{{ quiz.season.name }}{% endblock %} +{% block title %}{{ parent() }}{{ season.name }}{% endblock %} {% block body %} -

{{ 'Quiz'|trans }}: {{ quiz.season.name }} - {{ quiz.name }}

-
- {{ 'Make active'|trans }} - {% if quiz is same as (season.activeQuiz) %} - {{ 'Deactivate Quiz'|trans }} - {% endif %} - - -
+ {% set tabs = [ + {id: 'overview', label: 'Overview'|trans, route: 'tvdt_backoffice_quiz_overview'}, + {id: 'result', label: 'Results & Elimination'|trans, route: 'tvdt_backoffice_quiz_result'}, + {id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_quiz_candidates'}, + ] %} -
-

{{ 'Questions'|trans }}

-
- {%~ for question in quiz.questions ~%} -
-

- -

-
-
-
    - {%~ for answer in question.answers %} - {{ answer.text -}} - {%~ else %} - {{ 'There are no answers for this question'|trans -}} - {%~ endfor %} -
-
-
-
- {% else %} - {{ 'EMPTY'|trans }} - {% endfor %} -
-
-
-

{{ 'Score'|trans }}

- -

{{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }}

- - - - - - - - - - - - - {%~ for candidate in result ~%} - - - - - - - - - {% else %} - - - - {% endfor %} - -
{{ 'Candidate'|trans }}{{ 'Correct Answers'|trans }}{{ 'Corrections'|trans }}{{ 'Penalty'|trans }}{{ 'Score'|trans }}{{ 'Time'|trans }}
{{ candidate.name }}{{ candidate.correct|default('0') }} -
-
-
- -
-
- -
-
-
-
-
-
-
- -
-
- -
-
-
-
{{ candidate.score|default('x') }}{{ candidate.time.format('%i:%S') }}
{{ 'No results'|trans }}
-
- - {# Modal Clear #} - - - {# Modal Delete #} -