From 8013d6389e45e3eb8e43580050d6e2f257501156 Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Sat, 12 Sep 2026 17:18:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Ajout=20des=20billetteries=20priv=C3=A9es?= =?UTF-8?q?=20:=20lien=20unique=20=C3=A0=20mot=20de=20passe,=20prix=20et?= =?UTF-8?q?=20quota=20d=C3=A9finis=20au=20BO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/packages/backoffice_menu.yaml | 8 ++ config/packages/rate_limiter.yaml | 4 + config/routing/admin_event.yml | 18 +++ config/routing/event.yml | 4 + .../20260911090000_add_billeterie_privee.php | 28 ++++ phpstan-baseline.php | 18 --- .../Admin/Event/BilleteriePriveeAction.php | 74 ++++++++++ .../Event/DeleteBilleteriePriveeAction.php | 40 ++++++ .../Event/EditBilleteriePriveeAction.php | 88 ++++++++++++ .../Event/Ticket/BilleteriePriveeAction.php | 72 ++++++++++ .../Controller/Event/Ticket/TicketAction.php | 51 ++++++- .../Event/Entity/BilleteriePrivee.php | 54 +++++++ .../AppBundle/Event/Entity/Inscription.php | 27 ++++ .../Repository/BilleteriePriveeRepository.php | 66 +++++++++ .../Event/Form/BilleteriePriveeType.php | 119 ++++++++++++++++ sources/AppBundle/Event/Form/PurchaseType.php | 5 +- sources/AppBundle/Event/Form/TicketType.php | 75 ++++++++-- .../Event/Ticket/PurchaseTypeFactory.php | 12 +- .../admin/event/billeterie_privee.html.twig | 134 ++++++++++++++++++ .../event/billeterie_privee_edit.html.twig | 39 +++++ .../ticket/billeterie_privee_home.html.twig | 19 +++ .../BilleteriePriveeRepositoryTest.php | 75 ++++++++++ 22 files changed, 994 insertions(+), 36 deletions(-) create mode 100644 db/migrations/20260911090000_add_billeterie_privee.php create mode 100644 sources/AppBundle/Controller/Admin/Event/BilleteriePriveeAction.php create mode 100644 sources/AppBundle/Controller/Admin/Event/DeleteBilleteriePriveeAction.php create mode 100644 sources/AppBundle/Controller/Admin/Event/EditBilleteriePriveeAction.php create mode 100644 sources/AppBundle/Controller/Event/Ticket/BilleteriePriveeAction.php create mode 100644 sources/AppBundle/Event/Entity/BilleteriePrivee.php create mode 100644 sources/AppBundle/Event/Entity/Inscription.php create mode 100644 sources/AppBundle/Event/Entity/Repository/BilleteriePriveeRepository.php create mode 100644 sources/AppBundle/Event/Form/BilleteriePriveeType.php create mode 100644 templates/admin/event/billeterie_privee.html.twig create mode 100644 templates/admin/event/billeterie_privee_edit.html.twig create mode 100644 templates/event/ticket/billeterie_privee_home.html.twig create mode 100644 tests/integration/AppBundle/Event/Entity/Repository/BilleteriePriveeRepositoryTest.php diff --git a/config/packages/backoffice_menu.yaml b/config/packages/backoffice_menu.yaml index 4561ab99c..89e958398 100644 --- a/config/packages/backoffice_menu.yaml +++ b/config/packages/backoffice_menu.yaml @@ -159,6 +159,14 @@ parameters: url: '/admin/event/special_price' extra_routes: - admin_event_special_price + admin_event_billeterie_privee: + nom: 'Billeteries privées' + niveau: 'ROLE_ADMIN' + url: '/admin/event/billeterie-privee' + extra_routes: + - admin_event_billeterie_privee + - admin_event_billeterie_privee_edit + - admin_event_billeterie_privee_delete admin_event_prices: nom: 'Tarifications' niveau: 'ROLE_FORUM' diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml index 52885137d..611f1610b 100644 --- a/config/packages/rate_limiter.yaml +++ b/config/packages/rate_limiter.yaml @@ -4,3 +4,7 @@ framework: policy: sliding_window limit: 10 interval: '1 hour' + billeterie_privee: + policy: sliding_window + limit: 10 + interval: '1 hour' diff --git a/config/routing/admin_event.yml b/config/routing/admin_event.yml index d0e05d914..cffc10dc8 100644 --- a/config/routing/admin_event.yml +++ b/config/routing/admin_event.yml @@ -87,6 +87,24 @@ admin_event_special_price_extend: requirements: id: \d+ +admin_event_billeterie_privee: + path: /billeterie-privee + defaults: {_controller: AppBundle\Controller\Admin\Event\BilleteriePriveeAction} + +admin_event_billeterie_privee_edit: + path: /billeterie-privee/edit/{event}/{id} + defaults: {_controller: AppBundle\Controller\Admin\Event\EditBilleteriePriveeAction} + requirements: + event: \d+ + id: \d+ + +admin_event_billeterie_privee_delete: + path: /billeterie-privee/delete/{event}/{id} + defaults: {_controller: AppBundle\Controller\Admin\Event\DeleteBilleteriePriveeAction} + requirements: + event: \d+ + id: \d+ + admin_event_prices: path: /prices defaults: {_controller: AppBundle\Controller\Admin\Event\PricesAction } diff --git a/config/routing/event.yml b/config/routing/event.yml index 019721060..d422e0879 100644 --- a/config/routing/event.yml +++ b/config/routing/event.yml @@ -76,6 +76,10 @@ sponsor_ticket_form: path: /{eventSlug}/tickets/sponsor-form defaults: { _controller: AppBundle\Controller\Event\Ticket\SponsorTicketFormAction } +billeterie_privee_home: + path: /{eventSlug}/tickets/billeterie-privee/{token} + defaults: { _controller: AppBundle\Controller\Event\Ticket\BilleteriePriveeAction } + ticket: path: /{eventSlug}/tickets defaults: { _controller: AppBundle\Controller\Event\Ticket\TicketAction } diff --git a/db/migrations/20260911090000_add_billeterie_privee.php b/db/migrations/20260911090000_add_billeterie_privee.php new file mode 100644 index 000000000..ae3f1ad35 --- /dev/null +++ b/db/migrations/20260911090000_add_billeterie_privee.php @@ -0,0 +1,28 @@ +table('afup_forum_billeterie_privee') + ->addColumn('id_forum', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('nom', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('token', 'string', ['limit' => 64, 'null' => false]) + ->addColumn('mot_de_passe', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('id_tarif', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('prix', 'float', ['null' => false]) + ->addColumn('max_places', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('date_debut', 'datetime', ['null' => false]) + ->addColumn('date_fin', 'datetime', ['null' => false]) + ->addColumn('created_on', 'datetime', ['null' => false]) + ->addIndex(['token'], ['unique' => true]) + ->addIndex(['id_forum']) + ->save() + ; + } +} diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 8d46fc323..5a36f5372 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -5587,18 +5587,6 @@ 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Event/Form/TicketType.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$dateEnd of method AppBundle\\\\Event\\\\Model\\\\TicketEventType\\:\\:setDateEnd\\(\\) expects DateTime, DateTime\\|null given\\.$#', - 'identifier' => 'argument.type', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Event/Form/TicketType.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$dateStart of method AppBundle\\\\Event\\\\Model\\\\TicketEventType\\:\\:setDateStart\\(\\) expects DateTime, DateTime\\|null given\\.$#', - 'identifier' => 'argument.type', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Event/Form/TicketType.php', -]; $ignoreErrors[] = [ 'message' => '#^Parameter \\#1 \\$event of method AppBundle\\\\Event\\\\Model\\\\Repository\\\\TicketEventTypeRepository\\:\\:getTicketsByEvent\\(\\) expects AppBundle\\\\Event\\\\Model\\\\Event, AppBundle\\\\Event\\\\Model\\\\Event\\|null given\\.$#', 'identifier' => 'argument.type', @@ -5611,12 +5599,6 @@ 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Event/Form/TicketType.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$ticketType of method AppBundle\\\\Event\\\\Model\\\\TicketEventType\\:\\:setTicketType\\(\\) expects AppBundle\\\\Event\\\\Model\\\\TicketType, AppBundle\\\\Event\\\\Model\\\\TicketType\\|null given\\.$#', - 'identifier' => 'argument.type', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Event/Form/TicketType.php', -]; $ignoreErrors[] = [ 'message' => '#^Parameter \\#2 \\$event of method AppBundle\\\\Event\\\\Ticket\\\\TicketTypeAvailability\\:\\:getStock\\(\\) expects AppBundle\\\\Event\\\\Model\\\\Event, AppBundle\\\\Event\\\\Model\\\\Event\\|null given\\.$#', 'identifier' => 'argument.type', diff --git a/sources/AppBundle/Controller/Admin/Event/BilleteriePriveeAction.php b/sources/AppBundle/Controller/Admin/Event/BilleteriePriveeAction.php new file mode 100644 index 000000000..c8a298d60 --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Event/BilleteriePriveeAction.php @@ -0,0 +1,74 @@ +event; + + $billeteriePrivee = new BilleteriePrivee(); + $billeteriePrivee->token = base64_encode(random_bytes(30)); + $billeteriePrivee->eventId = (int) $event->getId(); + $billeteriePrivee->dateDebut = new DateTimeImmutable(); + $billeteriePrivee->dateFin = DateTimeImmutable::createFromMutable($event->getDateEndSales()); + $billeteriePrivee->createdOn = new DateTimeImmutable(); + + $form = $this->createForm(BilleteriePriveeType::class, $billeteriePrivee, [ + 'ticketTypes' => $this->ticketTypeRepository->getAll(), + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $motDePasse = $form->get('plainMotDePasse')->getData(); + if (is_string($motDePasse) && $motDePasse !== '') { + $billeteriePrivee->motDePasse = password_hash($motDePasse, PASSWORD_DEFAULT); + } + + $this->billeteriePriveeRepository->save($billeteriePrivee); + + $this->addFlash('notice', 'La billeterie privée a été enregistrée'); + + return $this->redirectToRoute('admin_event_billeterie_privee', [ + 'id' => (int) $event->getId(), + ]); + } + + $billeteries = []; + foreach ($this->billeteriePriveeRepository->findByEvent((int) $event->getId()) as $billeterie) { + $ticketType = $this->ticketTypeRepository->get($billeterie->ticketTypeId); + $billeteries[] = [ + 'billeterie_privee' => $billeterie, + 'ticket_type' => $ticketType !== null ? $ticketType->getPrettyName() : '', + 'places_prises' => $this->billeteriePriveeRepository->countPlacesPrisesParToken($billeterie->token), + ]; + } + + return $this->render('admin/event/billeterie_privee.html.twig', [ + 'billeteries_privees' => $billeteries, + 'event' => $event, + 'title' => 'Billeteries privées', + 'form' => $form->createView(), + 'event_select_form' => $eventSelection->selectForm(), + ]); + } +} diff --git a/sources/AppBundle/Controller/Admin/Event/DeleteBilleteriePriveeAction.php b/sources/AppBundle/Controller/Admin/Event/DeleteBilleteriePriveeAction.php new file mode 100644 index 000000000..6b40a4651 --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Event/DeleteBilleteriePriveeAction.php @@ -0,0 +1,40 @@ +eventActionHelper->getEventById($event); + + $billeteriePrivee = $this->billeteriePriveeRepository->find($id); + if (!$billeteriePrivee instanceof BilleteriePrivee || $billeteriePrivee->eventId !== $event->getId()) { + throw $this->createNotFoundException(sprintf('Impossible de trouver la billeterie privée ayant l\'identifiant : %s', $id)); + } + + if ($this->billeteriePriveeRepository->countPlacesPrisesParToken($billeteriePrivee->token) > 0) { + $this->addFlash('error', 'La billeterie privée ne peut pas être supprimée car des places ont déjà été prises.'); + return $this->redirectToRoute('admin_event_billeterie_privee', ['id' => $event->getId()]); + } + + $this->billeteriePriveeRepository->delete($billeteriePrivee); + + $this->addFlash('notice', 'La billeterie privée a été supprimée'); + return $this->redirectToRoute('admin_event_billeterie_privee', ['id' => $event->getId()]); + } +} diff --git a/sources/AppBundle/Controller/Admin/Event/EditBilleteriePriveeAction.php b/sources/AppBundle/Controller/Admin/Event/EditBilleteriePriveeAction.php new file mode 100644 index 000000000..84dd6251e --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Event/EditBilleteriePriveeAction.php @@ -0,0 +1,88 @@ +eventActionHelper->getEventById($event); + + $billeteriePrivee = $this->billeteriePriveeRepository->find($id); + if (!$billeteriePrivee instanceof BilleteriePrivee || $billeteriePrivee->eventId !== $event->getId()) { + throw $this->createNotFoundException(sprintf('Impossible de trouver la billeterie privée ayant l\'identifiant : %s', $id)); + } + + $maxPlacesActuelles = $billeteriePrivee->maxPlaces; + + $form = $this->createForm(BilleteriePriveeType::class, $billeteriePrivee, [ + 'ticketTypes' => $this->ticketTypeRepository->getAll(), + 'is_edit' => true, + ]); + + $form->handleRequest($request); + + $placesPrises = null; + + if ($form->isSubmitted()) { + if ($form->isValid()) { + $motDePasse = $form->get('plainMotDePasse')->getData(); + if (is_string($motDePasse) && $motDePasse !== '') { + $billeteriePrivee->motDePasse = password_hash($motDePasse, PASSWORD_DEFAULT); + } + } + + // Protection du quota : impossible de réduire sous le nombre de places déjà prises. + // Le comptage n'est fait que si le champ maxPlaces a été modifié + $maxPlacesSoumises = $form->get('maxPlaces')->getData(); + if (is_int($maxPlacesSoumises) && $maxPlacesSoumises < $maxPlacesActuelles) { + $placesPrises = $this->billeteriePriveeRepository->countPlacesPrisesParToken($billeteriePrivee->token); + if ($maxPlacesSoumises < $placesPrises) { + $form->get('maxPlaces')->addError(new FormError(sprintf( + '%d place(s) déjà prise(s) : le nombre de places ne peut pas être réduit en dessous.', + $placesPrises, + ))); + } + } + + if ($form->isValid()) { + $this->billeteriePriveeRepository->save($billeteriePrivee); + + $this->addFlash('notice', 'La billeterie privée a été modifiée'); + + return $this->redirectToRoute('admin_event_billeterie_privee', [ + 'id' => $event->getId(), + ]); + } + } else { + // Pas de soumission : on affiche le compteur de places prises + $placesPrises = $this->billeteriePriveeRepository->countPlacesPrisesParToken($billeteriePrivee->token); + } + + return $this->render('admin/event/billeterie_privee_edit.html.twig', [ + 'billeterie_privee' => $billeteriePrivee, + 'places_prises' => $placesPrises, + 'event' => $event, + 'title' => 'Billeteries privées - Modifier', + 'form' => $form->createView(), + ]); + } +} diff --git a/sources/AppBundle/Controller/Event/Ticket/BilleteriePriveeAction.php b/sources/AppBundle/Controller/Event/Ticket/BilleteriePriveeAction.php new file mode 100644 index 000000000..0e3d02b30 --- /dev/null +++ b/sources/AppBundle/Controller/Event/Ticket/BilleteriePriveeAction.php @@ -0,0 +1,72 @@ +eventActionHelper->getEvent($eventSlug); + + /** @var Session $session */ + $session = $request->getSession(); + + if ($session->has('billeterie_privee_token') === true) { + $session->remove('billeterie_privee_token'); + } + + if ($request->isMethod(Request::METHOD_POST)) { + $errors = []; + if (!$this->isCsrfTokenValid('billeterie_privee', (string) $request->request->get('_csrf_token'))) { + $errors[] = 'Jeton anti csrf invalide'; + } elseif (!$request->request->has('billeterie_privee_mot_de_passe')) { + $errors[] = 'Mot de passe absent'; + } else { + $limiter = $this->billeteriePriveeLimiter->create($request->getClientIp()); + $rateLimit = $limiter->consume(1); + $billeteriePrivee = $this->billeteriePriveeRepository->findOneByToken($token); + + if ( + !$rateLimit->isAccepted() + || $billeteriePrivee === null + || $billeteriePrivee->eventId !== $event->getId() + || !password_verify((string) $request->request->get('billeterie_privee_mot_de_passe'), $billeteriePrivee->motDePasse) + ) { + // Même message que si le token ou le mot de passe est faux, pour ne pas révéler lequel des deux pose problème + $errors[] = 'Token ou mot de passe invalide.'; + if ($rateLimit->isAccepted() === false) { + $errors[] = 'Trop de tentatives, merci de réessayer dans une heure.'; + } + } else { + $limiter->reset(); + $session->set('billeterie_privee_token', $billeteriePrivee->token); + + return $this->redirectToRoute('ticket', ['eventSlug' => $eventSlug]); + } + } + $session->getFlashBag()->setAll(['error' => $errors]); + return $this->redirectToRoute('billeterie_privee_home', ['eventSlug' => $eventSlug, 'token' => $token]); + } + + return $this->render('event/ticket/billeterie_privee_home.html.twig', [ + 'event' => $event, + 'token' => $token, + ]); + } +} diff --git a/sources/AppBundle/Controller/Event/Ticket/TicketAction.php b/sources/AppBundle/Controller/Event/Ticket/TicketAction.php index 1ff977a5b..22be2bda4 100644 --- a/sources/AppBundle/Controller/Event/Ticket/TicketAction.php +++ b/sources/AppBundle/Controller/Event/Ticket/TicketAction.php @@ -9,6 +9,8 @@ use AppBundle\Association\MemberType; use AppBundle\Association\Model\User; use AppBundle\Controller\Event\EventActionHelper; +use AppBundle\Event\Entity\BilleteriePrivee; +use AppBundle\Event\Entity\Repository\BilleteriePriveeRepository; use AppBundle\Event\Model\Invoice; use AppBundle\Event\Model\Repository\InvoiceRepository; use AppBundle\Event\Model\Repository\TicketEventTypeRepository; @@ -28,6 +30,7 @@ public function __construct( private readonly TicketRepository $ticketRepository, private readonly EventActionHelper $eventActionHelper, private readonly TicketEventTypeRepository $ticketEventTypeRepository, + private readonly BilleteriePriveeRepository $billeteriePriveeRepository, private readonly EventInvoiceReferenceGenerator $referenceGenerator, private readonly Authentication $authentication, ) {} @@ -36,15 +39,26 @@ public function __invoke($eventSlug, Request $request): Response { $event = $this->eventActionHelper->getEvent($eventSlug); - if ($event->getDateEndSales() < new \DateTime()) { + [$billeteriePrivee, $billeteriePriveeExpirée] = $this->getBilleteriePriveeFromSession($request, $event); + if ($billeteriePriveeExpirée || ($event->getDateEndSales() < new \DateTime() && $billeteriePrivee === null)) { return $this->render('event/ticket/sold_out.html.twig', ['event' => $event]); } + $placesRestantes = null; + if ($billeteriePrivee !== null) { + $placesRestantes = $billeteriePrivee->getPlacesRestantes( + $this->billeteriePriveeRepository->countPlacesPrisesParToken($billeteriePrivee->token), + ); + if ($placesRestantes <= 0) { + return $this->render('event/ticket/sold_out.html.twig', ['event' => $event]); + } + } + $purchaseFactory = $this->purchaseTypeFactory; $user = $this->authentication->getAfupUserOrNull(); - $purchaseForm = $purchaseFactory->getPurchaseForUser($event, $user, $request->query->get('token', null)); + $purchaseForm = $purchaseFactory->getPurchaseForUser($event, $user, $request->query->get('token', null), $billeteriePrivee, $placesRestantes); $purchaseForm->handleRequest($request); @@ -64,6 +78,14 @@ public function __invoke($eventSlug, Request $request): Response ->setNewsletter($purchaseForm->get('newsletterAfup')->getData()) ; + if ($billeteriePrivee !== null) { + // Re-vérification du quota au moment de la prise des places + $placesPrises = $this->billeteriePriveeRepository->countPlacesPrisesParToken($billeteriePrivee->token); + if ($placesPrises + count($tickets) > $billeteriePrivee->maxPlaces) { + return $this->render('event/ticket/sold_out.html.twig', ['event' => $event]); + } + } + if ($user instanceof User) { $memberId = $user->getId(); $memberType = MemberType::MemberPhysical->value; @@ -111,4 +133,29 @@ public function __invoke($eventSlug, Request $request): Response 'hasMembersTickets' => $this->ticketEventTypeRepository->doesEventHasRestrictedToMembersTickets($event, true, TicketEventTypeRepository::REMOVE_PAST_TICKETS), ]); } + + /** + * Charge la billeterie privée depuis la session. + * @return array{0: BilleteriePrivee|null, 1: bool} La billeterie, et true si elle existe mais sa fenêtre de vente est passée + */ + private function getBilleteriePriveeFromSession(Request $request, \AppBundle\Event\Model\Event $event): array + { + $token = $request->getSession()->get('billeterie_privee_token'); + if (!is_string($token)) { + return [null, false]; + } + + $billeteriePrivee = $this->billeteriePriveeRepository->findOneByToken($token); + if ($billeteriePrivee === null || $billeteriePrivee->eventId !== $event->getId()) { + $request->getSession()->remove('billeterie_privee_token'); + return [null, false]; + } + + $now = new \DateTimeImmutable(); + if ($billeteriePrivee->dateDebut > $now || $billeteriePrivee->dateFin < $now) { + return [null, true]; + } + + return [$billeteriePrivee, false]; + } } diff --git a/sources/AppBundle/Event/Entity/BilleteriePrivee.php b/sources/AppBundle/Event/Entity/BilleteriePrivee.php new file mode 100644 index 000000000..77a3af03f --- /dev/null +++ b/sources/AppBundle/Event/Entity/BilleteriePrivee.php @@ -0,0 +1,54 @@ +maxPlaces - $placesPrises); + } +} diff --git a/sources/AppBundle/Event/Entity/Inscription.php b/sources/AppBundle/Event/Entity/Inscription.php new file mode 100644 index 000000000..eaca4f316 --- /dev/null +++ b/sources/AppBundle/Event/Entity/Inscription.php @@ -0,0 +1,27 @@ + + */ +final class BilleteriePriveeRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BilleteriePrivee::class); + } + + /** + * @return list + */ + public function findByEvent(int $eventId): array + { + /** @var list $billeteries */ + $billeteries = $this->findBy(['eventId' => $eventId], ['id' => 'DESC']); + + return $billeteries; + } + + public function findOneByToken(?string $token): ?BilleteriePrivee + { + if (!is_string($token) || $token === '') { + return null; + } + + /** @var BilleteriePrivee|null $billeteriePrivee */ + $billeteriePrivee = $this->findOneBy(['token' => $token]); + + return $billeteriePrivee; + } + + /** + * Nombre de places déjà prises pour cette billeterie privée + * (les inscriptions annulées ne consomment pas de place). + */ + public function countPlacesPrisesParToken(string $token): int + { + $count = $this->getEntityManager() + ->createQueryBuilder() + ->select('COUNT(inscription.id)') + ->from(Inscription::class, 'inscription') + ->where('inscription.specialPriceToken = :token') + ->andWhere('inscription.etat <> :etatAnnule') + ->setParameter('token', $token) + ->setParameter('etatAnnule', Ticket::STATUS_CANCELLED) + ->getQuery() + ->getSingleScalarResult() + ; + + return (int) $count; + } +} diff --git a/sources/AppBundle/Event/Form/BilleteriePriveeType.php b/sources/AppBundle/Event/Form/BilleteriePriveeType.php new file mode 100644 index 000000000..842a719b7 --- /dev/null +++ b/sources/AppBundle/Event/Form/BilleteriePriveeType.php @@ -0,0 +1,119 @@ + + */ +class BilleteriePriveeType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $ticketTypes = $options['ticketTypes']; + if (!is_array($ticketTypes)) { + throw new \RuntimeException('L\'option ticketTypes doit être un tableau.'); + } + + $builder + ->add('nom', TextType::class, [ + 'label' => 'Nom de la billeterie (ex: Billetterie école XYZ)', + 'constraints' => [ + new NotBlank(), + ], + ]) + ->add('ticketTypeId', ChoiceType::class, [ + 'label' => 'Type de place', + 'choices' => $this->ticketTypesToChoices($ticketTypes), + 'choice_value' => 'id', + 'constraints' => [ + new NotBlank(), + ], + ]) + ->add('prix', MoneyType::class, [ + 'label' => 'Prix de la place', + 'currency' => 'EUR', + 'constraints' => [ + new GreaterThanOrEqual(0), + new NotBlank(), + ], + ]) + ->add('maxPlaces', IntegerType::class, [ + 'label' => 'Nombre de places disponibles', + 'constraints' => [ + new GreaterThanOrEqual(1), + new NotBlank(), + ], + ]) + ->add('dateDebut', DateTimeType::class, [ + 'label' => 'Date de début de vente', + 'widget' => 'single_text', + 'constraints' => [ + new NotBlank(), + ], + ]) + ->add('dateFin', DateTimeType::class, [ + 'label' => 'Date de fin de vente', + 'widget' => 'single_text', + 'constraints' => [ + new NotBlank(), + ], + ]) + ->add('plainMotDePasse', PasswordType::class, [ + 'mapped' => false, + 'required' => $options['is_edit'] === false, + 'label' => $options['is_edit'] ? 'Nouveau mot de passe' : 'Mot de passe', + 'attr' => [ + 'placeholder' => $options['is_edit'] ? 'Laisser vide pour conserver le mot de passe actuel' : '', + ], + 'constraints' => $options['is_edit'] ? [] : [ + new NotBlank(), + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => BilleteriePrivee::class, + 'is_edit' => false, + ]); + $resolver->setRequired([ + 'ticketTypes', + ]); + $resolver->setAllowedTypes('ticketTypes', 'array'); + } + + /** + * @param array $ticketTypes + * @return array + */ + private function ticketTypesToChoices(array $ticketTypes): array + { + $choices = []; + + foreach ($ticketTypes as $ticketType) { + if ($ticketType instanceof TicketType) { + $choices[$ticketType->getLabel()] = $ticketType; + } + } + + return $choices; + } +} diff --git a/sources/AppBundle/Event/Form/PurchaseType.php b/sources/AppBundle/Event/Form/PurchaseType.php index 3f6b3e263..7d80cee43 100644 --- a/sources/AppBundle/Event/Form/PurchaseType.php +++ b/sources/AppBundle/Event/Form/PurchaseType.php @@ -32,7 +32,7 @@ public function __construct( public function buildForm(FormBuilderInterface $builder, array $options): void { - $maxNbPersonne = $options['special_price_token'] ? 1 : self::MAX_NB_PERSONNES; + $maxNbPersonne = $options['max_personnes'] ?? ($options['special_price_token'] ? 1 : self::MAX_NB_PERSONNES); $nbPersonnesChoices = []; for ($i = 1; $i <= $maxNbPersonne; $i++) { @@ -56,6 +56,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'member_type' => $options['member_type'], 'is_cfp_submitter' => $options['is_cfp_submitter'], 'special_price_token' => $options['special_price_token'], + 'billeterie_privee' => $options['billeterie_privee'], 'error_bubbling' => false, ], ]) @@ -113,6 +114,8 @@ public function configureOptions(OptionsResolver $resolver): void 'member_type' => TicketType::MEMBER_NOT, 'is_cfp_submitter' => false, 'special_price_token' => null, + 'billeterie_privee' => null, + 'max_personnes' => null, 'event_id' => null, 'cascade_validation' => true, 'validation_groups' => function (): array { diff --git a/sources/AppBundle/Event/Form/TicketType.php b/sources/AppBundle/Event/Form/TicketType.php index cb0a05af0..905f5962a 100644 --- a/sources/AppBundle/Event/Form/TicketType.php +++ b/sources/AppBundle/Event/Form/TicketType.php @@ -6,12 +6,14 @@ use AppBundle\Antennes\AntenneRepository; use AppBundle\Association\Genre; +use AppBundle\Event\Entity\BilleteriePrivee; use AppBundle\Event\Model\Repository\EventRepository; use AppBundle\Event\Model\Repository\TicketEventTypeRepository; use AppBundle\Event\Model\Repository\TicketSpecialPriceRepository; use AppBundle\Event\Model\Repository\TicketTypeRepository; use AppBundle\Event\Model\Ticket; use AppBundle\Event\Model\TicketEventType; +use AppBundle\Event\Model\TicketSpecialPrice; use AppBundle\Event\Ticket\TicketTypeAvailability; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Exception\RuntimeException; @@ -93,17 +95,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $ticketSpecialPrice = $this->ticketSpecialPriceRepository->findUnusedToken($event, $options['special_price_token']); if (null !== $ticketSpecialPrice) { - $ticketType = $this->ticketTypeRepository->get(Ticket::TYPE_SPECIAL_PRICE); - - $eToken = new TicketEventType(); - $eToken->setDateStart($ticketSpecialPrice->getDateStart()); - $eToken->setDateEnd($ticketSpecialPrice->getDateEnd()); - $eToken->setPrice($ticketSpecialPrice->getPrice()); - $eToken->setTicketType($ticketType); - $eToken->setEventId($ticketSpecialPrice->getEventId()); - $eToken->setTicketTypeId(Ticket::TYPE_SPECIAL_PRICE); - $filteredEventTickets = []; - $filteredEventTickets[] = $eToken; + $filteredEventTickets = $this->createSpecialPriceTicketEventType($ticketSpecialPrice, $filteredEventTickets); + } + + $billeteriePrivee = $options['billeterie_privee']; + $choiceLabel = 'ticketType.prettyName'; + if ($billeteriePrivee instanceof BilleteriePrivee) { + $filteredEventTickets = $this->createBilleteriePriveeTicketEventType($billeteriePrivee, $filteredEventTickets); + $typeDePlace = $this->ticketTypeRepository->get($billeteriePrivee->ticketTypeId); + $choiceLabel = static fn(): string => $typeDePlace !== null ? $typeDePlace->getPrettyName() : 'Billet'; } $formEvent->getForm()->add('ticketEventType', ChoiceType::class, [ @@ -111,7 +111,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'multiple' => false, 'label' => 'Formule', 'choices' => $filteredEventTickets, - 'choice_label' => 'ticketType.prettyName', + 'choice_label' => $choiceLabel, 'error_bubbling' => false, 'choice_attr' => function (TicketEventType $type, $key, $index) use ($options, $event): array { $attr = [ @@ -171,6 +171,57 @@ public function configureOptions(OptionsResolver $resolver): void 'is_cfp_submitter' => false, 'event_id' => null, 'special_price_token' => null, + 'billeterie_privee' => null, ]); } + + /** + * @param TicketEventType[] $filteredEventTickets + * @return TicketEventType[] + */ + private function createSpecialPriceTicketEventType(TicketSpecialPrice $ticketSpecialPrice, array $filteredEventTickets): array + { + $ticketType = $this->ticketTypeRepository->get(Ticket::TYPE_SPECIAL_PRICE); + if (!$ticketType instanceof \AppBundle\Event\Model\TicketType) { + return $filteredEventTickets; + } + + $dateStart = $ticketSpecialPrice->getDateStart(); + $dateEnd = $ticketSpecialPrice->getDateEnd(); + if ($dateStart === null || $dateEnd === null) { + return $filteredEventTickets; + } + + $eToken = new TicketEventType(); + $eToken->setDateStart($dateStart); + $eToken->setDateEnd($dateEnd); + $eToken->setPrice($ticketSpecialPrice->getPrice()); + $eToken->setTicketType($ticketType); + $eToken->setEventId($ticketSpecialPrice->getEventId()); + $eToken->setTicketTypeId(Ticket::TYPE_SPECIAL_PRICE); + + return [$eToken]; + } + + /** + * @param TicketEventType[] $filteredEventTickets + * @return TicketEventType[] + */ + private function createBilleteriePriveeTicketEventType(BilleteriePrivee $billeteriePrivee, array $filteredEventTickets): array + { + $ticketType = $this->ticketTypeRepository->get(Ticket::TYPE_SPECIAL_PRICE); + if (!$ticketType instanceof \AppBundle\Event\Model\TicketType) { + return $filteredEventTickets; + } + + $eBilleterie = new TicketEventType(); + $eBilleterie->setDateStart(\DateTime::createFromImmutable($billeteriePrivee->dateDebut)); + $eBilleterie->setDateEnd(\DateTime::createFromImmutable($billeteriePrivee->dateFin)); + $eBilleterie->setPrice($billeteriePrivee->prix); + $eBilleterie->setTicketType($ticketType); + $eBilleterie->setEventId($billeteriePrivee->eventId); + $eBilleterie->setTicketTypeId(Ticket::TYPE_SPECIAL_PRICE); + + return [$eBilleterie]; + } } diff --git a/sources/AppBundle/Event/Ticket/PurchaseTypeFactory.php b/sources/AppBundle/Event/Ticket/PurchaseTypeFactory.php index ee56199f1..6b44a599c 100644 --- a/sources/AppBundle/Event/Ticket/PurchaseTypeFactory.php +++ b/sources/AppBundle/Event/Ticket/PurchaseTypeFactory.php @@ -5,6 +5,7 @@ namespace AppBundle\Event\Ticket; use AppBundle\Association\Model\User; +use AppBundle\Event\Entity\BilleteriePrivee; use AppBundle\Event\Form\PurchaseType; use AppBundle\Event\Form\TicketType; use AppBundle\Event\Model\Event; @@ -23,7 +24,7 @@ public function __construct( private readonly SpeakerRepository $speakerRepository, ) {} - public function getPurchaseForUser(Event $event, ?User $user = null, $specialPriceToken = null) + public function getPurchaseForUser(Event $event, ?User $user = null, $specialPriceToken = null, ?BilleteriePrivee $billeteriePrivee = null, ?int $nbPlacesRestantes = null) { $memberType = TicketType::MEMBER_NOT; @@ -43,7 +44,12 @@ public function getPurchaseForUser(Event $event, ?User $user = null, $specialPri $invoice = $this->invoiceFactory->createInvoiceForEvent($event); $ticket = new Ticket(); - if (null !== $specialPriceToken) { + if ($billeteriePrivee !== null) { + $ticket->setSpecialPriceToken($billeteriePrivee->token); + for ($i = 1; $i <= min($nbPlacesRestantes ?? 1, PurchaseType::MAX_NB_PERSONNES); $i++) { + $invoice->addTicket(clone $ticket); + } + } elseif (null !== $specialPriceToken) { $ticket->setSpecialPriceToken($specialPriceToken); $invoice->addTicket(clone $ticket); } else { @@ -55,7 +61,7 @@ public function getPurchaseForUser(Event $event, ?User $user = null, $specialPri return $this->formFactory->create( PurchaseType::class, $invoice, - ['event_id' => $event->getId(), 'member_type' => $memberType, 'is_cfp_submitter' => $isCfpSubmitter, 'special_price_token' => $specialPriceToken], + ['event_id' => $event->getId(), 'member_type' => $memberType, 'is_cfp_submitter' => $isCfpSubmitter, 'special_price_token' => $specialPriceToken, 'billeterie_privee' => $billeteriePrivee, 'max_personnes' => $nbPlacesRestantes], ); } } diff --git a/templates/admin/event/billeterie_privee.html.twig b/templates/admin/event/billeterie_privee.html.twig new file mode 100644 index 000000000..155f6ad8e --- /dev/null +++ b/templates/admin/event/billeterie_privee.html.twig @@ -0,0 +1,134 @@ +{% extends 'admin/base_with_header.html.twig' %} + +{% block content %} +

Billeteries privées

+ {% include 'admin/event/change_event.html.twig' with {form: event_select_form} only %} + + {% if billeteries_privees|length > 0 %} + + + + + + + + + + + + + + {% for billeterie in billeteries_privees %} + {% set sale = billeterie.billeterie_privee %} + + + + + + + + + + + + {% endfor %} + +
NomType de placePrixPlaces prises / maxDate de débutDate de finTokenLien
{{ sale.nom }}{{ billeterie.ticket_type }}{{ sale.prix }} € + {{ billeterie.places_prises }} / {{ sale.maxPlaces }} + {{ sale.dateDebut|date('d/m/Y H:i') }}{{ sale.dateFin|date('d/m/Y H:i') }}{{ sale.token }}Lien + + + + {% if billeterie.places_prises == 0 %} + + + + {% endif %} +
+ {% else %} +
+
+ + Aucune billeterie privée trouvée.{% if event == null %} Essayez de changer d'évènement{% endif %} +
+
+ {% endif %} + +
+

Nouvelle billeterie privée

+ +
+ + {{ form_start(form) }} + + + + + + + + + + + + + + + + + + + + + + + + +
{{ form_label(form.nom) }}{{ form_label(form.ticketTypeId) }}{{ form_label(form.prix) }}{{ form_label(form.maxPlaces) }}{{ form_label(form.dateDebut) }}{{ form_label(form.dateFin) }}{{ form_label(form.plainMotDePasse) }}
+
+ {{ form_errors(form.nom) }} + {{ form_widget(form.nom) }} +
+
+
+ {{ form_errors(form.ticketTypeId) }} + {{ form_widget(form.ticketTypeId) }} +
+
+
+ {{ form_errors(form.prix) }} + {{ form_widget(form.prix) }} +
+
+
+ {{ form_errors(form.maxPlaces) }} + {{ form_widget(form.maxPlaces) }} +
+
+
+ {{ form_errors(form.dateDebut) }} + {{ form_widget(form.dateDebut) }} +
+
+
+ {{ form_errors(form.dateFin) }} + {{ form_widget(form.dateFin) }} +
+
+
+ {{ form_errors(form.plainMotDePasse) }} + {{ form_widget(form.plainMotDePasse) }} +
+
+ +
+ + {{ form_errors(form) }} + + {{ form_end(form) }} +
+{% endblock %} diff --git a/templates/admin/event/billeterie_privee_edit.html.twig b/templates/admin/event/billeterie_privee_edit.html.twig new file mode 100644 index 000000000..9154e9e59 --- /dev/null +++ b/templates/admin/event/billeterie_privee_edit.html.twig @@ -0,0 +1,39 @@ +{% extends 'admin/base_with_header.html.twig' %} + +{% form_theme form 'form_theme_admin.html.twig' %} + +{% block content %} +

{{ title }}

+
+ {{ form_start(form) }} +

{{ billeterie_privee.nom }}

+

+ Token : {{ billeterie_privee.token }}
+ {% if places_prises is not null %} + Places déjà prises : {{ places_prises }} + {% endif %} +

+
+
+ {{ form_row(form.nom) }} + {{ form_row(form.ticketTypeId) }} + {{ form_row(form.prix) }} + {{ form_row(form.maxPlaces) }} + {{ form_row(form.dateDebut) }} + {{ form_row(form.dateFin) }} + {{ form_row(form.plainMotDePasse) }} +
+
+
+
+
+
+ + Annuler +
+
+
+ {{ form_end(form) }} +
+
+{% endblock %} diff --git a/templates/event/ticket/billeterie_privee_home.html.twig b/templates/event/ticket/billeterie_privee_home.html.twig new file mode 100644 index 000000000..c743d5077 --- /dev/null +++ b/templates/event/ticket/billeterie_privee_home.html.twig @@ -0,0 +1,19 @@ +{% extends 'event/ticket/base.html.twig' %} + +{% block metas %} + {{ parent() }} + +{% endblock %} + +{% block content %} +
+

{{ 'Billetterie privée'|trans }}: {{ event.title }}

+
+
+ + + +
+
+
+{% endblock %} diff --git a/tests/integration/AppBundle/Event/Entity/Repository/BilleteriePriveeRepositoryTest.php b/tests/integration/AppBundle/Event/Entity/Repository/BilleteriePriveeRepositoryTest.php new file mode 100644 index 000000000..2718873e1 --- /dev/null +++ b/tests/integration/AppBundle/Event/Entity/Repository/BilleteriePriveeRepositoryTest.php @@ -0,0 +1,75 @@ +get(BilleteriePriveeRepository::class); + $connection = self::getContainer()->get(Connection::class); + + $billeteriePrivee = $this->buildBilleteriePrivee('Billetterie école XYZ', 'TOKEN-XYZ'); + $billeteriePriveeRepository->save($billeteriePrivee); + $billeteriePriveeRepository->save($this->buildBilleteriePrivee('Billetterie entreprise ABC', 'TOKEN-ABC')); + + $fromEvent = $billeteriePriveeRepository->findByEvent(42); + self::assertCount(2, $fromEvent); + self::assertSame('Billetterie entreprise ABC', $fromEvent[0]->nom); + + $billeterie = $billeteriePriveeRepository->findOneByToken('token-xyz'); + self::assertInstanceOf(BilleteriePrivee::class, $billeterie); + self::assertSame(10, $billeterie->maxPlaces); + self::assertNull($billeteriePriveeRepository->findOneByToken('TOKEN-INTROUVABLE')); + self::assertNull($billeteriePriveeRepository->findOneByToken(null)); + + // Deux inscriptions actives + une annulée, sur deux billeteries différentes + // (le token doit correspondre exactement, la table des inscriptions est case-sensitive) + $this->insertInscription($connection, 'TOKEN-XYZ', 'A', 42, 0); + $this->insertInscription($connection, 'TOKEN-XYZ', 'B', 42, 0); + $this->insertInscription($connection, 'TOKEN-XYZ', 'C', 42, 1); + $this->insertInscription($connection, 'TOKEN-ABC', 'D', 42, 0); + + self::assertSame(2, $billeteriePriveeRepository->countPlacesPrisesParToken('TOKEN-XYZ')); + self::assertSame(1, $billeteriePriveeRepository->countPlacesPrisesParToken('TOKEN-ABC')); + self::assertSame(0, $billeteriePriveeRepository->countPlacesPrisesParToken('TOKEN-INTROUVABLE')); + + self::assertSame(8, $billeterie->getPlacesRestantes(2)); + } + + private function buildBilleteriePrivee(string $nom, string $token): BilleteriePrivee + { + $now = new \DateTimeImmutable(); + + $billeteriePrivee = new BilleteriePrivee(); + $billeteriePrivee->eventId = 42; + $billeteriePrivee->nom = $nom; + $billeteriePrivee->token = $token; + $billeteriePrivee->motDePasse = password_hash('secretxyz', PASSWORD_DEFAULT); + $billeteriePrivee->ticketTypeId = 1; + $billeteriePrivee->prix = 80.0; + $billeteriePrivee->maxPlaces = 10; + $billeteriePrivee->dateDebut = $now; + $billeteriePrivee->dateFin = $now->modify('+1 day'); + $billeteriePrivee->createdOn = $now; + + return $billeteriePrivee; + } + + private function insertInscription(Connection $connection, string $token, string $reference, int $forumId, int $etat): void + { + $connection->insert('afup_inscription_forum', [ + 'reference' => $reference, + 'special_price_token' => $token, + 'id_forum' => $forumId, + 'etat' => $etat, + ]); + } +} From 0b6ecfb4ffdbd8ca1789e65b46512b17820602a2 Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Sun, 13 Sep 2026 14:42:07 +0200 Subject: [PATCH 2/2] Donnee de test : billeterie privee (token fixe, mot de passe password) + variable eventSlug dans la porte publique --- db/seeds/BilleteriesPrivees.php | 35 +++++++++++++++++++ .../Event/Ticket/BilleteriePriveeAction.php | 1 + 2 files changed, 36 insertions(+) create mode 100644 db/seeds/BilleteriesPrivees.php diff --git a/db/seeds/BilleteriesPrivees.php b/db/seeds/BilleteriesPrivees.php new file mode 100644 index 000000000..ff6c6e488 --- /dev/null +++ b/db/seeds/BilleteriesPrivees.php @@ -0,0 +1,35 @@ +execute(sprintf("DELETE FROM afup_forum_billeterie_privee WHERE token = '%s'", self::TOKEN)); + + $data = [ + [ + 'id_forum' => Event::ID_FORUM, + 'nom' => 'Billetterie privée de test - école XYZ', + 'token' => self::TOKEN, + 'mot_de_passe' => password_hash('password', PASSWORD_DEFAULT), + 'id_tarif' => Tarif::TYPE_2_DAYS['id'], + 'prix' => 80.0, + 'max_places' => 50, + 'date_debut' => $now->format('Y-m-d H:i:s'), + 'date_fin' => $now->modify('+1 month')->format('Y-m-d H:i:s'), + 'created_on' => $now->format('Y-m-d H:i:s'), + ], + ]; + + $this->table('afup_forum_billeterie_privee')->insert($data)->saveData(); + } +} diff --git a/sources/AppBundle/Controller/Event/Ticket/BilleteriePriveeAction.php b/sources/AppBundle/Controller/Event/Ticket/BilleteriePriveeAction.php index 0e3d02b30..7cd55df44 100644 --- a/sources/AppBundle/Controller/Event/Ticket/BilleteriePriveeAction.php +++ b/sources/AppBundle/Controller/Event/Ticket/BilleteriePriveeAction.php @@ -66,6 +66,7 @@ public function __invoke(Request $request, string $eventSlug, string $token): Re return $this->render('event/ticket/billeterie_privee_home.html.twig', [ 'event' => $event, + 'eventSlug' => $eventSlug, 'token' => $token, ]); }