Refactor elimination feature and improve backoffice usability

This commit introduces a refactored EliminationFactory for better modularity, updates the elimination preparation process, and adds functionality to view eliminations. Backoffice templates and forms have been reorganized, minor translations were corrected, and additional assets like styles and flashes were included for enhanced user experience.
This commit is contained in:
2025-05-30 20:38:20 +02:00
parent e0350c8c31
commit d3e5cb0569
45 changed files with 1569 additions and 978 deletions

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Controller\Backoffice;
use App\Controller\AbstractController;
use App\Entity\Season;
use App\Entity\User;
use App\Form\CreateSeasonFormType;
use App\Repository\SeasonRepository;
use App\Service\QuizSpreadsheetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
#[IsGranted('ROLE_USER')]
final class BackofficeController extends AbstractController
{
public function __construct(
private readonly SeasonRepository $seasonRepository,
private readonly Security $security,
) {}
#[Route('/backoffice/', name: 'app_backoffice_index')]
public function index(): Response
{
$user = $this->getUser();
\assert($user instanceof User);
$seasons = $this->security->isGranted('ROLE_ADMIN')
? $this->seasonRepository->findAll()
: $this->seasonRepository->getSeasonsForUser($user);
return $this->render('backoffice/index.html.twig', [
'seasons' => $seasons,
]);
}
#[Route('/backoffice/add', name: 'app_backoffice_season_add', priority: 10)]
public function addSeason(Request $request, EntityManagerInterface $em): Response
{
$season = new Season();
$form = $this->createForm(CreateSeasonFormType::class, $season);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();
\assert($user instanceof User);
$season->addOwner($user);
$season->generateSeasonCode();
$em->persist($season);
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add.html.twig', ['form' => $form]);
}
#[Route('/backoffice/template', name: 'app_backoffice_template', priority: 10)]
public function getTemplate(QuizSpreadsheetService $excel): Response
{
$response = new StreamedResponse($excel->generateTemplate());
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment; filename="template.xlsx"');
return $response;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Controller\Backoffice;
use App\Controller\AbstractController;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Repository\CandidateRepository;
use App\Security\Voter\SeasonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
#[IsGranted('ROLE_USER')]
class QuizController extends AbstractController
{
public function __construct(
private readonly CandidateRepository $candidateRepository,
) {}
#[Route('/backoffice/season/{seasonCode}/quiz/{quiz}', name: 'app_backoffice_quiz')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function index(Season $season, Quiz $quiz): Response
{
return $this->render('backoffice/quiz.html.twig', [
'season' => $season,
'quiz' => $quiz,
'result' => $this->candidateRepository->getScores($quiz),
]);
}
#[Route('/backoffice/season/{seasonCode}/quiz/{quiz}/enable', name: 'app_backoffice_enable')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): Response
{
$season->setActiveQuiz($quiz);
$em->flush();
if ($quiz instanceof Quiz) {
return $this->redirectToRoute('app_backoffice_quiz', ['seasonCode' => $season->getSeasonCode(), 'quiz' => $quiz->getId()]);
}
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Controller\Backoffice;
use App\Controller\AbstractController;
use App\Entity\Candidate;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Enum\FlashType;
use App\Form\AddCandidatesFormType;
use App\Form\UploadQuizFormType;
use App\Security\Voter\SeasonVoter;
use App\Service\QuizSpreadsheetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
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\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
#[IsGranted('ROLE_USER')]
class SeasonController extends AbstractController
{
public function __construct(private readonly TranslatorInterface $translator, private EntityManagerInterface $em,
) {}
#[Route('/backoffice/season/{seasonCode}', name: 'app_backoffice_season')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function index(Season $season): Response
{
return $this->render('backoffice/season.html.twig', [
'season' => $season,
]);
}
#[Route('/backoffice/season/{seasonCode}/add_candidate', name: 'app_backoffice_add_candidates', priority: 10)]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function addCandidates(Season $season, Request $request): Response
{
$form = $this->createForm(AddCandidatesFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$candidates = $form->get('candidates')->getData();
foreach (explode("\r\n", (string) $candidates) as $candidate) {
$season->addCandidate(new Candidate($candidate));
}
$this->em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]);
}
#[Route('/backoffice/season/{seasonCode}/add', name: 'app_backoffice_quiz_add', priority: 10)]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function addQuiz(Request $request, Season $season, QuizSpreadsheetService $quizSpreadsheet): Response
{
$quiz = new Quiz();
$form = $this->createForm(UploadQuizFormType::class, $quiz);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/* @var UploadedFile $sheet */
$sheet = $form->get('sheet')->getData();
$quizSpreadsheet->xlsxToQuiz($quiz, $sheet);
$quiz->setSeason($season);
$this->em->persist($quiz);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
}
}

View File

@@ -1,167 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Candidate;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Entity\User;
use App\Enum\FlashType;
use App\Form\AddCandidatesFormType;
use App\Form\CreateSeasonFormType;
use App\Form\UploadQuizFormType;
use App\Repository\CandidateRepository;
use App\Repository\SeasonRepository;
use App\Security\Voter\SeasonVoter;
use App\Service\QuizSpreadsheetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
#[IsGranted('ROLE_USER')]
final class BackofficeController extends AbstractController
{
public function __construct(
private readonly SeasonRepository $seasonRepository,
private readonly CandidateRepository $candidateRepository,
private readonly Security $security,
private readonly TranslatorInterface $translator,
) {}
#[Route('/backoffice/', name: 'app_backoffice_index')]
public function index(): Response
{
$user = $this->getUser();
\assert($user instanceof User);
$seasons = $this->security->isGranted('ROLE_ADMIN')
? $this->seasonRepository->findAll()
: $this->seasonRepository->getSeasonsForUser($user);
return $this->render('backoffice/index.html.twig', [
'seasons' => $seasons,
]);
}
#[Route('/backoffice/add', name: 'app_backoffice_season_add', priority: 10)]
public function seasonAdd(Request $request, EntityManagerInterface $em): Response
{
$season = new Season();
$form = $this->createForm(CreateSeasonFormType::class, $season);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();
\assert($user instanceof User);
$season->addOwner($user);
$season->generateSeasonCode();
$em->persist($season);
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add.html.twig', ['form' => $form]);
}
#[Route('/backoffice/{seasonCode}', name: 'app_backoffice_season')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function season(Season $season): Response
{
return $this->render('backoffice/season.html.twig', [
'season' => $season,
]);
}
#[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', [
'season' => $season,
'quiz' => $quiz,
'result' => $this->candidateRepository->getScores($quiz),
]);
}
#[Route('/backoffice/{seasonCode}/{quiz}/enable', name: 'app_backoffice_enable')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function enableQuiz(Season $season, ?Quiz $quiz, EntityManagerInterface $em): Response
{
$season->setActiveQuiz($quiz);
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
#[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);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$candidates = $form->get('candidates')->getData();
foreach (explode("\r\n", (string) $candidates) as $candidate) {
$season->addCandidate(new Candidate($candidate));
}
$em->flush();
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form]);
}
#[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();
$form = $this->createForm(UploadQuizFormType::class, $quiz);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/* @var UploadedFile $sheet */
$sheet = $form->get('sheet')->getData();
$quizSpreadsheet->xlsxToQuiz($quiz, $sheet);
$quiz->setSeason($season);
$em->persist($quiz);
$em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Quiz Added!'));
return $this->redirectToRoute('app_backoffice_season', ['seasonCode' => $season->getSeasonCode()]);
}
return $this->render('/backoffice/quiz_add.html.twig', ['form' => $form, 'season' => $season]);
}
#[Route('/backoffice/template', name: 'app_backoffice_template', priority: 10)]
public function getTemplate(QuizSpreadsheetService $excel): Response
{
$response = new StreamedResponse($excel->generateTemplate());
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment; filename="template.xlsx"');
return $response;
}
}

View File

@@ -4,17 +4,19 @@ declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use App\Enum\FlashType;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsController]
final class LoginController extends AbstractController
{
#[Route(path: '/login', name: 'app_login_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
public function login(AuthenticationUtils $authenticationUtils, TranslatorInterface $translator): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
@@ -22,7 +24,11 @@ final class LoginController extends AbstractController
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('login/login.html.twig', [
if ($error instanceof AuthenticationException) {
$this->addFlash(FlashType::Danger, $translator->trans($error->getMessageKey(), $error->getMessageData(), 'security'));
}
return $this->render('backoffice/login/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Elimination;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Factory\EliminationFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -13,12 +15,19 @@ use Symfony\Component\Routing\Attribute\Route;
final class PrepareEliminationController extends AbstractController
{
#[Route('/backoffice/elimination/{seasonCode}/{quiz}/prepare', name: 'app_prepare_elimination')]
public function index(Season $season, Quiz $quiz): Response
public function index(Season $season, Quiz $quiz, EliminationFactory $eliminationFactory): Response
{
return $this->render('prepare_elimination/index.html.twig', [
$elimination = $eliminationFactory->createEliminationFromQuiz($quiz);
return $this->redirectToRoute('app_prepare_elimination_view', ['elimination' => $elimination->getId()]);
}
#[Route('/backoffice/elimination/{elimination}', name: 'app_prepare_elimination_view')]
public function viewElimination(Elimination $elimination): Response
{
return $this->render('backoffice/prepare_elimination/index.html.twig', [
'controller_name' => 'PrepareEliminationController',
'season' => $season,
'quiz' => $quiz,
'elimination' => $elimination,
]);
}
}

View File

@@ -48,7 +48,7 @@ final class RegistrationController extends AbstractController
(new TemplatedEmail())
->to((string) $user->getEmail())
->subject($this->translator->trans('Please Confirm your Email'))
->htmlTemplate('registration/confirmation_email.html.twig')
->htmlTemplate('backoffice/registration/confirmation_email.html.twig')
);
$response = $security->login($user, 'form_login', 'main');
@@ -57,7 +57,7 @@ final class RegistrationController extends AbstractController
return $response;
}
return $this->render('registration/register.html.twig', [
return $this->render('backoffice/registration/register.html.twig', [
'registrationForm' => $form,
]);
}

View File

@@ -7,27 +7,37 @@ namespace App\Entity;
use App\Repository\EliminationRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Safe\DateTimeImmutable;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: EliminationRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Elimination
{
public const string SCREEN_GREEN = 'green';
public const string SCREEN_RED = 'red';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
private Uuid $id;
#[ORM\ManyToOne(inversedBy: 'eliminations')]
#[ORM\JoinColumn(nullable: false)]
private Quiz $quiz;
/** @var array<string, mixed> */
#[ORM\Column(type: Types::JSON)]
private array $data = [];
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: false)]
private \DateTimeImmutable $created;
public function __construct(
#[ORM\ManyToOne(inversedBy: 'eliminations')]
#[ORM\JoinColumn(nullable: false)]
private Quiz $quiz,
) {}
public function getId(): Uuid
{
return $this->id;
@@ -40,22 +50,26 @@ class Elimination
}
/** @param array<string, mixed> $data */
public function setData(array $data): static
public function setData(array $data): self
{
$this->data = $data;
return $this;
}
public function setQuiz(Quiz $quiz): self
{
$this->quiz = $quiz;
return $this;
}
public function getQuiz(): Quiz
{
return $this->quiz;
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->created = new DateTimeImmutable();
}
public function getCreated(): \DateTimeInterface
{
return $this->created;
}
}

View File

@@ -38,11 +38,12 @@ class Quiz
#[ORM\OneToMany(targetEntity: Correction::class, mappedBy: 'quiz', orphanRemoval: true)]
private Collection $corrections;
#[ORM\Column(nullable: true)]
private ?int $dropouts = null;
#[ORM\Column(nullable: false, options: ['default' => 1])]
private int $dropouts = 1;
/** @var Collection<int, Elimination> */
#[ORM\OneToMany(targetEntity: Elimination::class, mappedBy: 'quiz', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['created' => 'DESC'])]
private Collection $eliminations;
public function __construct()
@@ -113,12 +114,12 @@ class Quiz
return $this;
}
public function getDropouts(): ?int
public function getDropouts(): int
{
return $this->dropouts;
}
public function setDropouts(?int $dropouts): static
public function setDropouts(int $dropouts): static
{
$this->dropouts = $dropouts;

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Factory;
use App\Entity\Elimination;
use App\Entity\Quiz;
use App\Repository\CandidateRepository;
use Doctrine\ORM\EntityManagerInterface;
final readonly class EliminationFactory
{
public function __construct(
private CandidateRepository $candidateRepository,
private EntityManagerInterface $em,
) {}
public function createEliminationFromQuiz(Quiz $quiz): Elimination
{
$elimination = new Elimination($quiz);
$this->em->persist($elimination);
$scores = $this->candidateRepository->getScores($quiz);
$simpleScores = [];
foreach (array_reverse($scores) as $i => $score) {
$simpleScores[$score['name']] = $i < $quiz->getDropouts() ? Elimination::SCREEN_RED : Elimination::SCREEN_GREEN;
}
$elimination->setData($simpleScores);
$this->em->flush();
return $elimination;
}
}

View File

@@ -19,7 +19,7 @@ class AddCandidatesFormType extends AbstractType
{
$builder
->add('candidates', TextareaType::class, [
'label' => $this->translator->trans('Candidates'),
'label' => $this->translator->trans('Candidates'), 'translation_domain' => false,
])
;
}

View File

@@ -21,6 +21,7 @@ class CreateSeasonFormType extends AbstractType
$builder
->add('name', TextType::class, [
'label' => $this->translator->trans('Season Name'),
'translation_domain' => false,
])
;
}

View File

@@ -18,7 +18,11 @@ class EnterNameType extends AbstractType
{
$builder
->add('name', TextType::class,
['required' => true, 'label' => $this->translator->trans('Enter your name')],
[
'required' => true,
'label' => $this->translator->trans('Enter your name'),
'translation_domain' => false,
],
)
;
}

View File

@@ -8,6 +8,7 @@ use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
@@ -27,11 +28,16 @@ class RegistrationFormType extends AbstractType
->add('email', EmailType::class, [
'label' => $this->translator->trans('Email'),
'attr' => ['autocomplete' => 'email'],
'translation_domain' => false,
])
->add('plainPassword', PasswordType::class, [
'label' => $this->translator->trans('Password'),
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'invalid_message' => $this->translator->trans('The password fields must match.'),
'options' => ['attr' => ['class' => 'password-field']],
'required' => true,
'first_options' => ['label' => $this->translator->trans('Password')],
'second_options' => ['label' => $this->translator->trans('Repeat Password')],
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
@@ -43,6 +49,7 @@ class RegistrationFormType extends AbstractType
'max' => 4096,
]),
],
'translation_domain' => false,
])
;
}

View File

@@ -20,7 +20,7 @@ class SelectSeasonType extends AbstractType
{
$builder
->add('season_code', TextType::class,
['required' => true, 'constraints' => new Regex(pattern: "/^[A-Za-z\d]{5}$/"), 'label' => $this->translator->trans('Season Code')]
['required' => true, 'constraints' => new Regex(pattern: "/^[A-Za-z\d]{5}$/"), 'label' => $this->translator->trans('Season Code'), 'translation_domain' => false]
)
;
}

View File

@@ -23,11 +23,13 @@ class UploadQuizFormType extends AbstractType
$builder
->add('name', TextType::class, [
'label' => $this->translator->trans('Quiz name'),
'translation_domain' => false,
])
->add('sheet', FileType::class, [
'label' => $this->translator->trans('Quiz (xlsx)'),
'mapped' => false,
'required' => true,
'translation_domain' => false,
'constraints' => [
new File([
'maxSize' => '1024k',

View File

@@ -13,11 +13,12 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\Persistence\ManagerRegistry;
use Safe\Exceptions\UrlException;
use Symfony\Component\Uid\Uuid;
/**
* @extends ServiceEntityRepository<Candidate>
*
* @phpstan-type Result array{0: Candidate, correct: int, time: \DateInterval, corrections?: float, score: float}
* @phpstan-type Result array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float, score: float}
* @phpstan-type ResultList list<Result>
*/
class CandidateRepository extends ServiceEntityRepository
@@ -56,7 +57,7 @@ class CandidateRepository extends ServiceEntityRepository
public function getScores(Quiz $quiz): array
{
$scoreTimeQb = $this->createQueryBuilder('c', 'c.id')
->select('c', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'max(ga.created) - min(ga.created) as time')
->select('c.id', 'c.name', 'sum(case when a.isRightAnswer = true then 1 else 0 end) as correct', 'max(ga.created) - min(ga.created) as time')
->join('c.givenAnswers', 'ga')
->join('ga.answer', 'a')
->where('ga.quiz = :quiz')
@@ -64,7 +65,7 @@ class CandidateRepository extends ServiceEntityRepository
->setParameter('quiz', $quiz);
$correctionsQb = $this->createQueryBuilder('c', 'c.id')
->select('c', 'cor.amount as corrections')
->select('c.id', 'cor.amount as corrections')
->innerJoin(Correction::class, 'cor', Join::WITH, 'cor.candidate = c and cor.quiz = :quiz')
->setParameter('quiz', $quiz);
@@ -74,7 +75,7 @@ class CandidateRepository extends ServiceEntityRepository
}
/**
* @param array<string, array{0: Candidate, correct: int, time: \DateInterval, corrections?: float}> $in
* @param array<string, array{id: Uuid, name: string, correct: int, time: \DateInterval, corrections?: float}> $in
*
* @return array<string, Result>
* */

View File

@@ -52,14 +52,14 @@ class QuizSpreadsheetService
}
/** @throws SpreadsheetDataException */
public function xlsxToQuiz(Quiz $quiz, File $file): Quiz
public function xlsxToQuiz(Quiz $quiz, File $file): void
{
$spreadsheet = $this->readSheet($file);
$sheet = $spreadsheet->getSheet($spreadsheet->getFirstSheetIndex());
$answerLines = \array_slice($sheet->toArray(formatData: false), 1);
return $this->fillQuizFromArray($quiz, $answerLines);
$this->fillQuizFromArray($quiz, $answerLines);
}
private function readSheet(File $file): Spreadsheet