diff --git a/.env.dist b/.env.dist index a249f6cdd..62dd0620f 100644 --- a/.env.dist +++ b/.env.dist @@ -74,3 +74,7 @@ QR_CODE_SALT="" IDE_USED="phpstorm" AFUP_GLOBAL_MENU_EVENT_LABEL="Forum PHP" + +WORDPRESS_BASE_URI=https://host.docker.internal:9225 +WORDPRESS_AUTH="foo:bar" +WORDPRESS_INTERVIEW_SPEAKER_TAG_ID=19 diff --git a/app/config/packages/backoffice_menu.yaml b/app/config/packages/backoffice_menu.yaml index fa12b7de9..d27776f20 100644 --- a/app/config/packages/backoffice_menu.yaml +++ b/app/config/packages/backoffice_menu.yaml @@ -209,6 +209,15 @@ parameters: url: '/admin/event/speakers-management' extra_routes: - admin_event_speakers_management + admin_event_interview_list: + nom: 'Interviews' + niveau: 'ROLE_FORUM' + url: '/admin/event/interviews' + extra_routes: + - admin_event_interview_list + - admin_event_interview_add + - admin_event_interview_edit + - admin_event_interview_config admin_event_speakers_expenses: nom: 'Justificatifs speakers' niveau: 'ROLE_FORUM' diff --git a/app/config/packages/doctrine.yaml b/app/config/packages/doctrine.yaml index 6b14a03ab..ccb7b7e3f 100644 --- a/app/config/packages/doctrine.yaml +++ b/app/config/packages/doctrine.yaml @@ -56,6 +56,12 @@ doctrine: is_bundle: false dir: '%kernel.project_dir%/../sources/AppBundle/Veille/Entity' prefix: 'AppBundle\Veille\Entity' + Event: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/../sources/AppBundle/Event/Entity' + prefix: 'AppBundle\Event\Entity' + when@test: doctrine: dbal: diff --git a/app/config/packages/http_client.yaml b/app/config/packages/http_client.yaml index 99b4c1f4b..2a9924dec 100644 --- a/app/config/packages/http_client.yaml +++ b/app/config/packages/http_client.yaml @@ -13,3 +13,16 @@ framework: base_uri: 'https://bsky.social' http_client.bluesky_embed: base_uri: 'https://embed.bsky.app' + http_client.wordpress: + base_uri: '%env(WORDPRESS_BASE_URI)%' + auth_basic: '%env(WORDPRESS_AUTH)%' + headers: + Accept: 'application/json' + +when@dev: + framework: + http_client: + scoped_clients: + http_client.wordpress: + verify_peer: false + verify_host: false diff --git a/app/config/packages/security.yaml b/app/config/packages/security.yaml index f52a40746..23c44f36d 100644 --- a/app/config/packages/security.yaml +++ b/app/config/packages/security.yaml @@ -59,6 +59,7 @@ security: - { path: ^/admin/members/users, roles: ROLE_ADMIN } - { path: ^/admin/members/companies, roles: ROLE_ADMIN } - { path: ^/admin/event/speakers-management, roles: ROLE_FORUM } + - { path: ^/admin/event/interviews, roles: ROLE_FORUM } - { path: ^/admin/event, roles: ROLE_ADMIN } - { path: ^/admin/members/general_meeting, roles: ROLE_ADMIN } - { path: ^/admin/members/general_meeting_vote, roles: ROLE_ADMIN } diff --git a/app/config/routing/admin_event.yml b/app/config/routing/admin_event.yml index da7d5aba8..725b3e9d4 100644 --- a/app/config/routing/admin_event.yml +++ b/app/config/routing/admin_event.yml @@ -53,6 +53,26 @@ admin_event_speakers_management: path: /speakers-management defaults: {_controller: AppBundle\Controller\Admin\Event\SpeakersManagementAction} +admin_event_interview_list: + path: /interviews + defaults: {_controller: AppBundle\Controller\Admin\Event\Interview\ListAction} + +admin_event_interview_add: + path: /interviews/add + defaults: {_controller: AppBundle\Controller\Admin\Event\Interview\AddOrEditAction} + +admin_event_interview_edit: + path: /interviews/{interviewId} + defaults: {_controller: AppBundle\Controller\Admin\Event\Interview\AddOrEditAction} + requirements: + interviewId: \d+ + +admin_event_interview_config: + path: /interviews/{eventId}/config + defaults: {_controller: AppBundle\Controller\Admin\Event\Interview\ConfigAction} + requirements: + eventId: \d+ + admin_event_speakers_expenses: path: /speakers-expenses defaults: {_controller: AppBundle\Controller\Admin\Event\SpeakersExpensesAction} diff --git a/app/config/services.yml b/app/config/services.yml index 182bb38e1..9aebec65a 100644 --- a/app/config/services.yml +++ b/app/config/services.yml @@ -221,3 +221,5 @@ services: class: Symfony\Component\AssetMapper\CompiledAssetMapperConfigReader arguments: $directory: '%kernel.project_dir%/../htdocs/dist' + + AppBundle\Event\Wordpress\WordpressClient: '@AppBundle\Event\Wordpress\HttpWordpressClient' diff --git a/app/config/services_test.yaml b/app/config/services_test.yaml index 4e959811e..b1924aa42 100644 --- a/app/config/services_test.yaml +++ b/app/config/services_test.yaml @@ -6,3 +6,6 @@ services: http_client.bluesky_embed: class: Symfony\Component\HttpClient\MockHttpClient + + Afup\Tests\Support\Wordpress\FakeWordpressClient: ~ + AppBundle\Event\Wordpress\WordpressClient: '@Afup\Tests\Support\Wordpress\FakeWordpressClient' diff --git a/db/migrations/20260630120000_create_interview_tables.php b/db/migrations/20260630120000_create_interview_tables.php new file mode 100644 index 000000000..197aefbeb --- /dev/null +++ b/db/migrations/20260630120000_create_interview_tables.php @@ -0,0 +1,59 @@ +table('interview') + ->addColumn('event_id', 'integer', [ + 'null' => false, + 'signed' => false, + ]) + ->addColumn('date_publication', 'datetime', ['null' => false]) + ->addColumn('wordpress_post_id', 'integer', [ + 'null' => true, + 'default' => null, + ]) + ->create(); + + $this->table('interview_question') + ->addColumn('interview_id', 'integer', [ + 'null' => false, + 'signed' => false, + ]) + ->addColumn('position', 'integer', [ + 'null' => false, + 'default' => 0, + ]) + ->addColumn('question', 'text', ['null' => false]) + ->addColumn('reponse', 'text', ['null' => false]) + ->addForeignKey('interview_id', 'interview', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->create(); + + // Une interview peut avoir un ou plusieurs speakers, mais un speaker ne peut avoir + // qu'une seule interview par event. Vu que pour chaque event, les speakers sont uniques, + // une unicité sur speaker_id suffit, et permet de rechercher rapidement par speaker. + $this->table('interview_speaker') + ->addColumn('interview_id', 'integer', [ + 'null' => false, + 'signed' => false, + ]) + ->addColumn('speaker_id', 'integer', [ + 'null' => false, + 'signed' => false, + ]) + ->addForeignKey('interview_id', 'interview', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->addIndex('speaker_id', ['unique' => true]) + ->create(); + + $this->table('afup_forum') + ->addColumn('interviews_wp_category_id', 'integer', ['null' => true]) + ->addColumn('interviews_intro', 'text', ['null' => true]) + ->addColumn('interviews_cta_text', 'text', ['null' => true]) + ->save(); + } +} diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 41c7961ef..56a9ff1b7 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -3523,36 +3523,12 @@ 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Controller/Admin/Event/SpeakerInfosAction.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Cannot access offset \'hasExpensesFiles\' on mixed\\.$#', - 'identifier' => 'offsetAccess.nonOffsetAccessible', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Admin/Event/SpeakersExpensesAction.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Cannot access offset \'speaker\' on mixed\\.$#', - 'identifier' => 'offsetAccess.nonOffsetAccessible', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Admin/Event/SpeakersExpensesAction.php', -]; $ignoreErrors[] = [ 'message' => '#^Parameter \\#1 \\$speaker of method AppBundle\\\\SpeakerInfos\\\\SpeakersExpensesStorage\\:\\:getFiles\\(\\) expects AppBundle\\\\Event\\\\Model\\\\Speaker, mixed given\\.$#', 'identifier' => 'argument.type', 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Controller/Admin/Event/SpeakersExpensesAction.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Cannot access offset \'hasExpensesFiles\' on mixed\\.$#', - 'identifier' => 'offsetAccess.nonOffsetAccessible', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Admin/Event/SpeakersManagementAction.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Cannot access offset \'speaker\' on mixed\\.$#', - 'identifier' => 'offsetAccess.nonOffsetAccessible', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Admin/Event/SpeakersManagementAction.php', -]; $ignoreErrors[] = [ 'message' => '#^Parameter \\#1 \\$speaker of method AppBundle\\\\SpeakerInfos\\\\SpeakersExpensesStorage\\:\\:getFiles\\(\\) expects AppBundle\\\\Event\\\\Model\\\\Speaker, mixed given\\.$#', 'identifier' => 'argument.type', @@ -4759,36 +4735,12 @@ 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Cannot access offset \'\\.aggregation\' on mixed\\.$#', - 'identifier' => 'offsetAccess.nonOffsetAccessible', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php', -]; $ignoreErrors[] = [ 'message' => '#^Cannot access offset \'speaker\' on mixed\\.$#', 'identifier' => 'offsetAccess.nonOffsetAccessible', 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Cannot call method getId\\(\\) on mixed\\.$#', - 'identifier' => 'method.nonObject', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Cannot cast mixed to string\\.$#', - 'identifier' => 'cast.string', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Possibly invalid array key type mixed\\.$#', - 'identifier' => 'offsetAccess.invalidOffset', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php', -]; $ignoreErrors[] = [ 'message' => '#^Cannot cast mixed to int\\.$#', 'identifier' => 'cast.int', @@ -7321,12 +7273,6 @@ 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Method AppBundle\\\\Event\\\\Model\\\\Repository\\\\SpeakerRepository\\:\\:getScheduledSpeakersByEvent\\(\\) return type with generic interface CCMBenchmark\\\\Ting\\\\Repository\\\\CollectionInterface does not specify its types\\: T$#', - 'identifier' => 'missingType.generics', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php', -]; $ignoreErrors[] = [ 'message' => '#^Method AppBundle\\\\Event\\\\Model\\\\Repository\\\\SpeakerRepository\\:\\:getSpeakersByEvent\\(\\) return type with generic interface CCMBenchmark\\\\Ting\\\\Repository\\\\CollectionInterface does not specify its types\\: T$#', 'identifier' => 'missingType.generics', diff --git a/sources/AppBundle/Controller/Admin/Event/Interview/AddOrEditAction.php b/sources/AppBundle/Controller/Admin/Event/Interview/AddOrEditAction.php new file mode 100644 index 000000000..45bde0fe1 --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Event/Interview/AddOrEditAction.php @@ -0,0 +1,144 @@ +event; + + if ($interviewId !== null) { + $interview = $this->interviewRepository->find($interviewId); + if ($interview === null) { + throw $this->createNotFoundException('Interview introuvable.'); + } + } else { + $interview = new Interview(); + $interview->eventId = (int) $event->getId(); + $interview->addQuestion(new InterviewQuestion()); + } + + $form = $this->createForm(InterviewType::class, $interview, [ + 'available_speakers' => $this->getAvailableSpeakers($event, $interview), + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->interviewRepository->save($interview); + + $savedToWordpress = $this->saveToWordpress($interview, $event); + + if ($savedToWordpress) { + $this->addFlash('success', "L'interview a été enregistrée en base et sur WordPress."); + } else { + $this->addFlash('notice', "L'interview a été enregistrée en base uniquement. L'enregistrement sur WordPress n'a pas fonctionné."); + } + + return $this->redirectToRoute('admin_event_interview_list', ['id' => $event->getId()]); + } + + return $this->render('admin/event/interview/edit.html.twig', [ + 'event' => $event, + 'interview' => $interview, + 'form' => $form->createView(), + ]); + } + + /** + * @return array + */ + private function getAvailableSpeakers(Event $event, Interview $interview): array + { + $scheduledSpeakerIds = []; + foreach ($this->tingSpeakerRepository->getScheduledSpeakersByEvent($event, true) as $row) { + $speaker = $row['speaker'] ?? null; + if ($speaker instanceof TingSpeaker) { + $scheduledSpeakerIds[] = (int) $speaker->getId(); + } + } + + $existingInterviews = $this->interviewRepository->findIndexedBySpeakerIds($scheduledSpeakerIds); + + $available = []; + foreach ($scheduledSpeakerIds as $id) { + if (!isset($existingInterviews[$id]) || in_array($id, $interview->getSpeakerIds(), true)) { + $available[] = $this->doctrineSpeakerRepository->find($id); + } + } + + $available = array_filter($available); + usort($available, fn(DoctrineSpeaker $a, DoctrineSpeaker $b) => strcmp($a->label, $b->label)); + + $choices = []; + /** @var DoctrineSpeaker $speaker */ + foreach ($available as $speaker) { + $choices[$speaker->label] = $speaker; + } + + return $choices; + } + + private function saveToWordpress(Interview $interview, Event $event): bool + { + $plannedTalks = []; + foreach ($interview->speakers as $speaker) { + foreach ($this->talkRepository->getTalksBySpeaker($event, $speaker->id) as $talk) { + foreach ($this->talkRepository->getByTalkWithSpeakers($talk) as $row) { + if ($row['talk'] instanceof Talk) { + $plannedTalks[] = $row['talk']; + } + } + } + } + + try { + $wordpressPostId = $this->wordpressClient->persistInterview( + $interview, + $event, + $interview->speakers->toArray(), + $plannedTalks, + ); + + $interview->wordpressPostId = $wordpressPostId; + $this->interviewRepository->save($interview); + + return true; + } catch (\Exception $e) { + $this->addFlash('error', 'Erreur WordPress : ' . $e->getMessage()); + $this->logger->error('Erreur WordPress : ' . $e->getMessage()); + + return false; + } + } +} diff --git a/sources/AppBundle/Controller/Admin/Event/Interview/ConfigAction.php b/sources/AppBundle/Controller/Admin/Event/Interview/ConfigAction.php new file mode 100644 index 000000000..351bcda56 --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Event/Interview/ConfigAction.php @@ -0,0 +1,40 @@ +eventRepository->get($eventId); + if (!$event instanceof Event) { + throw $this->createNotFoundException(); + } + + $form = $this->createForm(InterviewConfigType::class, $event); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->eventRepository->save($event); + $this->addFlash('success', 'Configuration enregistrée.'); + + return $this->redirectToRoute('admin_event_interview_list', ['id' => $event->getId()]); + } + + return $this->render('admin/event/interview/config.html.twig', [ + 'event' => $event, + 'form' => $form->createView(), + ]); + } +} diff --git a/sources/AppBundle/Controller/Admin/Event/Interview/ListAction.php b/sources/AppBundle/Controller/Admin/Event/Interview/ListAction.php new file mode 100644 index 000000000..49962a950 --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Event/Interview/ListAction.php @@ -0,0 +1,97 @@ +event; + + $speakers = []; + foreach ($this->speakerRepository->getScheduledSpeakersByEvent($event, true) as $row) { + $speaker = $row['speaker'] ?? null; + if (!$speaker instanceof Speaker) { + continue; + } + + $speakers[(int) $speaker->getId()] = $speaker; + } + + $interviews = $this->interviewRepository->findBySpeakerIds(array_keys($speakers)); + + $interviewsWithSpeakers = []; + $speakerIdsWithInterview = []; + foreach ($interviews as $interview) { + $interviewsWithSpeakers[] = [ + 'interview' => $interview, + 'speakers' => $this->resolveSpeakers($interview, $speakers), + ]; + $speakerIdsWithInterview = array_merge($speakerIdsWithInterview, $interview->getSpeakerIds()); + } + + usort( + $interviewsWithSpeakers, + fn(array $a, array $b) => $this->firstSpeakerLabel($a['speakers']) <=> $this->firstSpeakerLabel($b['speakers']), + ); + + $speakersWithoutInterview = array_values(array_filter( + $speakers, + fn(Speaker $speaker) => !in_array($speaker->getId(), $speakerIdsWithInterview, true), + )); + + return $this->render('admin/event/interview/list.html.twig', [ + 'event' => $event, + 'interviews' => $interviewsWithSpeakers, + 'speakersWithoutInterview' => $speakersWithoutInterview, + 'event_select_form' => $eventSelection->selectForm(), + 'now' => new \DateTimeImmutable(), + 'wordpressBaseUri' => $this->wordpressBaseUri, + ]); + } + + /** + * @param array $speakers + * + * @return list + */ + private function resolveSpeakers(Interview $interview, array $speakers): array + { + $resolved = []; + foreach ($interview->getSpeakerIds() as $speakerId) { + if (isset($speakers[$speakerId])) { + $resolved[] = $speakers[$speakerId]; + } + } + + usort($resolved, fn(Speaker $a, Speaker $b) => $a->getLabel() <=> $b->getLabel()); + + return $resolved; + } + + /** + * @param list $speakers + */ + private function firstSpeakerLabel(array $speakers): string + { + return $speakers === [] ? '' : $speakers[0]->getLabel(); + } +} diff --git a/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php b/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php index 2ea8f839b..23454286b 100644 --- a/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php +++ b/sources/AppBundle/Controller/Event/Blog/TalkWidgetAction.php @@ -4,37 +4,54 @@ namespace AppBundle\Controller\Event\Blog; +use AppBundle\Event\Entity\Repository\InterviewRepository; use AppBundle\Event\Model\Repository\TalkRepository; +use AppBundle\Event\Model\Speaker; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; final class TalkWidgetAction extends AbstractController { - public function __construct(private readonly TalkRepository $talkRepository) {} + public function __construct( + private readonly TalkRepository $talkRepository, + private readonly InterviewRepository $interviewRepository, + ) {} public function __invoke(Request $request): Response { - $talks = $this->talkRepository->getBy(['id' => explode(',', (string) $request->get('ids'))]); + $widgetType = $request->query->get('type', 'all'); + $talks = $this->talkRepository->getBy(['id' => explode(',', (string) $request->query->get('ids'))]); $speakers = []; $talksInfos = []; foreach ($talks as $talk) { foreach ($this->talkRepository->getByTalkWithSpeakers($talk) as $row) { $talksInfos[] = $row; + /** @var Speaker $speaker */ foreach ($row['.aggregation']['speaker'] as $speaker) { - $speakers[$speaker->getId()] = $speaker; + $speakers[(int) $speaker->getId()] = $speaker; } } } - return $this->render( - 'blog/talk.html.twig', - [ - 'talks_infos' => $talksInfos, - 'speakers' => $speakers, - 'widget_type' => $request->get('type', 'all'), - ], - ); + $data = [ + 'talks_infos' => $talksInfos, + 'speakers' => $speakers, + 'widget_type' => $widgetType, + 'questions' => [], + ]; + + if ($widgetType === 'interview' || $widgetType === 'all' && count($speakers) > 0) { + $firstSpeakerId = array_key_first($speakers); + + if (is_int($firstSpeakerId)) { + $data['questions'] = $this->interviewRepository + ->findOneBySpeakerId($firstSpeakerId) + ->questions ?? []; + } + } + + return $this->render('blog/talk.html.twig', $data); } } diff --git a/sources/AppBundle/Event/Entity/Interview.php b/sources/AppBundle/Event/Entity/Interview.php new file mode 100644 index 000000000..9cb4465b7 --- /dev/null +++ b/sources/AppBundle/Event/Entity/Interview.php @@ -0,0 +1,74 @@ + */ + #[ORM\JoinTable(name: 'interview_speaker')] + #[ORM\JoinColumn(name: 'interview_id', referencedColumnName: 'id')] + #[ORM\InverseJoinColumn(name: 'speaker_id', referencedColumnName: 'conferencier_id', unique: true)] + #[ORM\ManyToMany(targetEntity: Speaker::class)] + public Collection $speakers; + + /** @var Collection */ + #[ORM\OneToMany( + targetEntity: InterviewQuestion::class, + mappedBy: 'interview', + cascade: ['persist', 'remove'], + orphanRemoval: true, + )] + #[ORM\OrderBy(['position' => 'ASC'])] + public Collection $questions; + + public function __construct() + { + $this->speakers = new ArrayCollection(); + $this->questions = new ArrayCollection(); + } + + /** + * @return array + */ + public function getSpeakerIds(): array + { + return $this->speakers->map(static fn(Speaker $speaker): int => $speaker->id)->toArray(); + } + + public function addQuestion(InterviewQuestion $question): void + { + if (!$this->questions->contains($question)) { + $this->questions->add($question); + $question->interview = $this; + } + } + + public function removeQuestion(InterviewQuestion $question): void + { + $this->questions->removeElement($question); + } +} diff --git a/sources/AppBundle/Event/Entity/InterviewQuestion.php b/sources/AppBundle/Event/Entity/InterviewQuestion.php new file mode 100644 index 000000000..d66bfd286 --- /dev/null +++ b/sources/AppBundle/Event/Entity/InterviewQuestion.php @@ -0,0 +1,30 @@ + + */ +final class InterviewRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Interview::class); + } + + public function findOneBySpeakerId(int $speakerId): ?Interview + { + /** @var Interview|null $interview */ + $interview = ($qb = $this->createQueryBuilder('i')) + ->innerJoin('i.speakers', 's') + ->where($qb->expr()->eq('s.id', ':id')) + ->setParameter('id', $speakerId) + ->getQuery() + ->getOneOrNullResult(); + + return $interview; + } + + /** + * @param list $speakerIds + * + * @return array + */ + public function findIndexedBySpeakerIds(array $speakerIds): array + { + $indexed = []; + foreach ($this->findBySpeakerIds($speakerIds) as $interview) { + foreach ($interview->getSpeakerIds() as $speakerId) { + if (in_array($speakerId, $speakerIds, true)) { + $indexed[$speakerId] = $interview; + } + } + } + + return $indexed; + } + + /** + * @param list $speakerIds + * + * @return list + */ + public function findBySpeakerIds(array $speakerIds): array + { + if ($speakerIds === []) { + return []; + } + + return ($qb = $this->createQueryBuilder('i')) + ->innerJoin('i.speakers', 's') + ->where($qb->expr()->in('s.id', ':ids')) + ->setParameter('ids', $speakerIds) + ->getQuery() + ->getResult(); + } +} diff --git a/sources/AppBundle/Event/Entity/Repository/SpeakerRepository.php b/sources/AppBundle/Event/Entity/Repository/SpeakerRepository.php new file mode 100644 index 000000000..ab64cd08f --- /dev/null +++ b/sources/AppBundle/Event/Entity/Repository/SpeakerRepository.php @@ -0,0 +1,20 @@ + + */ +final class SpeakerRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Speaker::class); + } +} diff --git a/sources/AppBundle/Event/Entity/Speaker.php b/sources/AppBundle/Event/Entity/Speaker.php new file mode 100644 index 000000000..29d260d61 --- /dev/null +++ b/sources/AppBundle/Event/Entity/Speaker.php @@ -0,0 +1,28 @@ + $this->firstname . " " . ($this->lastname ? mb_strtoupper($this->lastname) : null); + } +} diff --git a/sources/AppBundle/Event/Form/InterviewConfigType.php b/sources/AppBundle/Event/Form/InterviewConfigType.php new file mode 100644 index 000000000..3c4cc47c1 --- /dev/null +++ b/sources/AppBundle/Event/Form/InterviewConfigType.php @@ -0,0 +1,72 @@ + + */ +final class InterviewConfigType extends AbstractType +{ + public function __construct( + private readonly WordpressClient $wordpressClient, + #[Autowire('%env(WORDPRESS_BASE_URI)%')] + private readonly string $wordpressBaseUri, + ) {} + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = []; + foreach ($this->wordpressClient->listCategories() as $category) { + $choices[$category->name] = $category->id; + } + + $builder + ->add('interviewsWordpressCategoryId', ChoiceType::class, [ + 'label' => 'Catégorie', + 'required' => true, + 'choices' => $choices, + 'placeholder' => '-- Choisir --', + 'help' => 'La catégorie doit être créée côté WordPress', + 'help_html' => true, + ]) + ->add('interviewsIntro', TextareaType::class, [ + 'label' => "Texte d'introduction des articles", + 'attr' => ['rows' => 5], + 'required' => true, + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Type('string'), + ], + ]) + ->add('interviewsCtaText', TextType::class, [ + 'label' => 'Texte du bouton de la billetterie', + 'required' => true, + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Type('string'), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Event::class, + 'csrf_protection' => false, + ]); + } +} diff --git a/sources/AppBundle/Event/Form/InterviewQuestionType.php b/sources/AppBundle/Event/Form/InterviewQuestionType.php new file mode 100644 index 000000000..5e8b88fa4 --- /dev/null +++ b/sources/AppBundle/Event/Form/InterviewQuestionType.php @@ -0,0 +1,51 @@ + + */ +class InterviewQuestionType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('question', TextareaType::class, [ + 'label' => 'Question', + 'attr' => ['rows' => 2], + 'required' => true, + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Type('string'), + ], + ]) + ->add('reponse', TextareaType::class, [ + 'label' => 'Réponse', + 'attr' => ['rows' => 10], + 'required' => true, + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Type('string'), + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => InterviewQuestion::class, + ]); + } +} diff --git a/sources/AppBundle/Event/Form/InterviewType.php b/sources/AppBundle/Event/Form/InterviewType.php new file mode 100644 index 000000000..7417887be --- /dev/null +++ b/sources/AppBundle/Event/Form/InterviewType.php @@ -0,0 +1,85 @@ + + */ +class InterviewType extends AbstractType +{ + /** + * @param array{available_speakers: array} $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('speakers', EntityType::class, [ + 'required' => true, + 'label' => 'Speaker(s)', + 'class' => Speaker::class, + 'choice_label' => 'label', + 'choices' => $options['available_speakers'], + 'attr' => ['size' => count($options['available_speakers'])], + 'multiple' => true, + 'expanded' => false, + 'constraints' => [ + new Count(min: 1, minMessage: 'Sélectionnez au moins un speaker.'), + ], + 'help' => 'Il est possible de sélectionner plusieurs speakers pour une même interview.', + ]) + ->add('datePublication', DateTimeType::class, [ + 'widget' => 'single_text', + 'label' => 'Date de publication', + 'required' => true, + 'help' => "Une date dans le passé déclenche une publication immédiate. Une date dans le futur créée l'interview en status Planifié. Si cette date est modifiée ici, elle le sera aussi sur WordPress.", + ]) + ->add('questions', CollectionType::class, [ + 'entry_type' => InterviewQuestionType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'prototype' => true, + 'label' => false, + 'constraints' => [ + new Count(min: 1, minMessage: 'Il faut au moins une question.'), + ], + ]) + ; + + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { + $interview = $event->getData(); + if (!$interview instanceof Interview) { + return; + } + + $position = 0; + foreach ($interview->questions as $question) { + $question->position = $position++; + } + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Interview::class, + ]); + + $resolver->setRequired('available_speakers'); + $resolver->setAllowedTypes('available_speakers', 'array'); + } +} diff --git a/sources/AppBundle/Event/Model/Event.php b/sources/AppBundle/Event/Model/Event.php index ef9d2153a..0c195f0ab 100644 --- a/sources/AppBundle/Event/Model/Event.php +++ b/sources/AppBundle/Event/Model/Event.php @@ -101,6 +101,12 @@ class Event implements NotifyPropertyInterface private bool $hasThemes = false; + private ?int $interviewsWordpressCategoryId = null; + + private ?string $interviewsIntro = null; + + private ?string $interviewsCtaText = null; + /** * @return int */ @@ -727,6 +733,46 @@ public function setArchivedAt(?DateTime $archivedAt): self return $this; } + public function getInterviewsWordpressCategoryId(): ?int + { + return $this->interviewsWordpressCategoryId; + } + + public function setInterviewsWordpressCategoryId(?int $interviewsWordpressCategoryId): void + { + $this->propertyChanged('interviewsWordpressCategoryId', $this->interviewsWordpressCategoryId, $interviewsWordpressCategoryId); + $this->interviewsWordpressCategoryId = $interviewsWordpressCategoryId; + } + + public function getInterviewsIntro(): ?string + { + return $this->interviewsIntro; + } + + public function setInterviewsIntro(?string $interviewsIntro): void + { + $this->propertyChanged('interviewsIntro', $this->interviewsIntro, $interviewsIntro); + $this->interviewsIntro = $interviewsIntro; + } + + public function getInterviewsCtaText(): ?string + { + return $this->interviewsCtaText; + } + + public function setInterviewsCtaText(?string $interviewsCtaText): void + { + $this->propertyChanged('interviewsCtaText', $this->interviewsCtaText, $interviewsCtaText); + $this->interviewsCtaText = $interviewsCtaText; + } + + public function hasAllWordpressConfig(): bool + { + return $this->interviewsWordpressCategoryId !== null + && $this->interviewsIntro !== null + && $this->interviewsCtaText !== null; + } + public function isOnline(): bool { return str_contains($this->getPath(), 'enligne'); diff --git a/sources/AppBundle/Event/Model/Repository/EventRepository.php b/sources/AppBundle/Event/Model/Repository/EventRepository.php index 0ef3b9e53..7a668a443 100644 --- a/sources/AppBundle/Event/Model/Repository/EventRepository.php +++ b/sources/AppBundle/Event/Model/Repository/EventRepository.php @@ -547,6 +547,21 @@ public static function initMetadata(SerializerFactoryInterface $serializerFactor 'type' => 'bool', 'serializer' => Boolean::class, ]) + ->addField([ + 'columnName' => 'interviews_wp_category_id', + 'fieldName' => 'interviewsWordpressCategoryId', + 'type' => 'int', + ]) + ->addField([ + 'columnName' => 'interviews_intro', + 'fieldName' => 'interviewsIntro', + 'type' => 'string', + ]) + ->addField([ + 'columnName' => 'interviews_cta_text', + 'fieldName' => 'interviewsCtaText', + 'type' => 'string', + ]) ; return $metadata; diff --git a/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php b/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php index a69c96206..5a92ad0df 100644 --- a/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php +++ b/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php @@ -43,7 +43,7 @@ public function getSpeakersByTalk(Talk $talk) /** * Retrieve speakers with a scheduled talk for a given event * @param bool $returnTalksThatWillBePublished - * @return CollectionInterface + * @return CollectionInterface>&iterable> */ public function getScheduledSpeakersByEvent(Event $event, $returnTalksThatWillBePublished = false): CollectionInterface { diff --git a/sources/AppBundle/Event/Model/Repository/TalkRepository.php b/sources/AppBundle/Event/Model/Repository/TalkRepository.php index 5d7a07899..35223b0d2 100644 --- a/sources/AppBundle/Event/Model/Repository/TalkRepository.php +++ b/sources/AppBundle/Event/Model/Repository/TalkRepository.php @@ -61,8 +61,12 @@ public function getNumberOfTalksByEventAndLanguage(Event|int $event, $languageCo /** * @return CollectionInterface&iterable */ - public function getTalksBySpeaker(Event $event, Speaker $speaker) + public function getTalksBySpeaker(Event $event, Speaker|int $speakerOrSpeakerId) { + if ($speakerOrSpeakerId instanceof Speaker) { + $speakerOrSpeakerId = $speakerOrSpeakerId->getId(); + } + $query = $this->getPreparedQuery( 'SELECT sessions.session_id, titre, abstract, id_forum, sessions.plannifie, skill, genre FROM afup_sessions sessions @@ -71,7 +75,7 @@ public function getTalksBySpeaker(Event $event, Speaker $speaker) ORDER BY titre LIMIT 0, 20 ', - )->setParams(['event' => $event->getId(), 'speaker' => $speaker->getId()]); + )->setParams(['event' => $event->getId(), 'speaker' => $speakerOrSpeakerId]); return $query->query($this->getCollection(new HydratorSingleObject())); } @@ -205,7 +209,7 @@ public function getNewTalksToRate(Event $event, GithubUser $user, $randomSeed, $ } /** - * @return CollectionInterface + * @return CollectionInterface&iterable> */ public function getByTalkWithSpeakers(Talk $talk) { diff --git a/sources/AppBundle/Event/Wordpress/Dto/Category.php b/sources/AppBundle/Event/Wordpress/Dto/Category.php new file mode 100644 index 000000000..677c45025 --- /dev/null +++ b/sources/AppBundle/Event/Wordpress/Dto/Category.php @@ -0,0 +1,14 @@ + */ + public array $questions, + public int $talkId, + ) {} +} diff --git a/sources/AppBundle/Event/Wordpress/Dto/Question.php b/sources/AppBundle/Event/Wordpress/Dto/Question.php new file mode 100644 index 000000000..bbd4b1ce8 --- /dev/null +++ b/sources/AppBundle/Event/Wordpress/Dto/Question.php @@ -0,0 +1,13 @@ +wordpressClient->request('GET', '/wp-json/wp/v2/categories?orderby=id&order=desc&per_page=100&page=1'); + + return $this->mapperBuilder + ->allowSuperfluousKeys() + ->mapper() + ->map('array<' . Category::class . '>', Source::json($response->getContent())); + } + + public function persistInterview(Interview $interview, Event $event, array $speakers, array $talks): ?int + { + $categoryId = $event->getInterviewsWordpressCategoryId(); + if ($categoryId === null) { + throw new LogicException('Configuration de la catégorie WordPress manquante'); + } + + $category = $this->getCategory($categoryId); + if ($category === null) { + throw new LogicException('Catégorie WordPress ' . $categoryId . ' inexistante'); + } + + $slugger = new AsciiSlugger(); + + $speakerNames = array_map( + fn(Speaker $speaker) => trim($speaker->firstname . ' ' . $speaker->lastname), + $speakers, + ); + + $content = $this->twig->render('blog/interview.html.twig', [ + 'talkIds' => implode(',', array_unique(array_map(fn(Talk $talk) => $talk->getId(), $talks))), + 'questions' => $interview->questions, + 'event' => $event, + 'category' => $category, + ]); + + $path = '/wp-json/wp/v2/posts'; + if ($interview->wordpressPostId) { + $path .= '/' . $interview->wordpressPostId; + } + + $response = $this->wordpressClient->request('POST', $path, [ + 'json' => [ + 'title' => 'La parole est aux speakers : ' . implode(', ', $speakerNames), + 'content' => $content, + 'categories' => [$event->getInterviewsWordpressCategoryId()], + 'tags' => [$this->interviewSpeakerTagId], + 'slug' => vsprintf('%s-interview-%s', [ + $event->getPath(), + $slugger->slug(implode(' ', $speakerNames)), + ]), + + // La publication est déléguée à WordPress. Si une date dans le passé est renseignée, + // l'interview sera publiée directement. Sinon, WordPress la publiera à la date indiquée. + 'date' => $interview->datePublication->format('Y-m-d\TH:i:s'), + 'status' => 'publish', + ], + ]); + + $id = $response->toArray()['id'] ?? null; + + return is_int($id) ? $id : null; + } + + private function getCategory(int $id): ?Category + { + $response = $this->wordpressClient->request('GET', '/wp-json/wp/v2/categories/' . $id); + + if ($response->getStatusCode() !== 200) { + return null; + } + + return $this->mapperBuilder + ->allowSuperfluousKeys() + ->mapper() + ->map(Category::class, Source::json($response->getContent())); + } +} diff --git a/sources/AppBundle/Event/Wordpress/WordpressClient.php b/sources/AppBundle/Event/Wordpress/WordpressClient.php new file mode 100644 index 000000000..cf9f5bf93 --- /dev/null +++ b/sources/AppBundle/Event/Wordpress/WordpressClient.php @@ -0,0 +1,25 @@ + + */ + public function listCategories(): array; + + /** + * @param array $speakers + * @param array $talks + */ + public function persistInterview(Interview $interview, Event $event, array $speakers, array $talks): ?int; +} diff --git a/templates/admin/event/interview/_javascript.html.twig b/templates/admin/event/interview/_javascript.html.twig new file mode 100644 index 000000000..d3423dda7 --- /dev/null +++ b/templates/admin/event/interview/_javascript.html.twig @@ -0,0 +1,52 @@ + diff --git a/templates/admin/event/interview/_prototype.html.twig b/templates/admin/event/interview/_prototype.html.twig new file mode 100644 index 000000000..36822c527 --- /dev/null +++ b/templates/admin/event/interview/_prototype.html.twig @@ -0,0 +1,8 @@ +
+
+
+
Nouvelle question
+
+
+{{ form_row(form.questions.vars.prototype.question) }} +{{ form_row(form.questions.vars.prototype.reponse) }} diff --git a/templates/admin/event/interview/config.html.twig b/templates/admin/event/interview/config.html.twig new file mode 100644 index 000000000..634d6ef82 --- /dev/null +++ b/templates/admin/event/interview/config.html.twig @@ -0,0 +1,24 @@ +{% extends 'admin/base_with_header.html.twig' %} + +{% block content %} +

Configuration des interview {{ event.title }}

+ + {% form_theme form 'form_theme_admin.html.twig' %} + + {{ form_start(form) }} + {{ form_errors(form) }} + +
+
+ {{ form_row(form.interviewsWordpressCategoryId) }} + {{ form_row(form.interviewsIntro) }} + {{ form_row(form.interviewsCtaText) }} +
+
+ +
+ +
+ + {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/event/interview/edit.html.twig b/templates/admin/event/interview/edit.html.twig new file mode 100644 index 000000000..4c33fb799 --- /dev/null +++ b/templates/admin/event/interview/edit.html.twig @@ -0,0 +1,64 @@ +{% extends 'admin/base_with_header.html.twig' %} + +{% block content %} +

Interview {{ event.title }}{% if interview.id is defined %} #{{ interview.id }}{% endif %}

+ + {% form_theme form 'form_theme_admin.html.twig' %} + + {{ form_start(form) }} + {{ form_errors(form) }} + +
+

Speaker(s)

+
+
+ {{ form_row(form.speakers) }} +
+
+ +
+

Publication

+
+
+ {{ form_row(form.datePublication) }} +
+
+ +
+

Questions

+ + {% set index = form.questions|length %} +
+ {% for questionForm in form.questions %} +
+
+
+
+
Question {{ loop.index }}
+
+
+ {{ form_row(questionForm.question) }} + {{ form_row(questionForm.reponse) }} +
+ {% endfor %} +
+ +
+ +
+ +
+ + {{ form_end(form) }} + + {% include 'admin/event/interview/_javascript.html.twig' %} +{% endblock %} diff --git a/templates/admin/event/interview/list.html.twig b/templates/admin/event/interview/list.html.twig new file mode 100644 index 000000000..d19ebfd30 --- /dev/null +++ b/templates/admin/event/interview/list.html.twig @@ -0,0 +1,96 @@ +{% extends 'admin/base_with_header.html.twig' %} + +{% block content %} +

Interviews

+ {% include 'admin/event/change_event.html.twig' with {form: event_select_form} only %} + + {% if interviews|length == 0 and speakersWithoutInterview|length == 0 %} +
+

Il n'y a aucun speaker planifié pour cet évènement.

+
+ {% else %} + + {% if event.hasAllWordpressConfig() %} + + + + + + + + + + + + {% for row in interviews %} + + + + + + {% else %} + + + + {% endfor %} + +
Speaker(s)Date de publication
{{ row.speakers|map(s => s.label)|join(', ') }} + {{ row.interview.datePublication|date('d/m/Y H:i') }} + + + + + {% if row.interview.wordpressPostId is not null %} + + + + {% endif %} +
Aucune interview pour le moment.
+ + {% if speakersWithoutInterview|length > 0 %} +
+
Speakers sans interview
+

{{ speakersWithoutInterview|map(s => s.label)|join(', ') }}

+
+ {% endif %} + {% else %} +
+ +
+
+ Configuration WordPress manquante +
+

+ Sans cette configuration les interviews programmées ne seront pas postées. +

+ + + Configurer + +
+
+ {% endif %} + + {% endif %} +{% endblock %} diff --git a/templates/blog/interview.html.twig b/templates/blog/interview.html.twig new file mode 100644 index 000000000..e472b4c4b --- /dev/null +++ b/templates/blog/interview.html.twig @@ -0,0 +1,25 @@ + +

{{ event.interviewsIntro }}

+ + + +[afup_website path="/blog/talk_widget?ids={{ talkIds }}&type=all"] + + + + + + + +

Autres interviews

+ + + +[display-posts category_display=false category="{{ category.slug }}" posts_per_page=50 exclude_current=true tag="interview-speaker"] + diff --git a/templates/blog/talk.html.twig b/templates/blog/talk.html.twig index ab0e1aaae..4ffb9c5c2 100644 --- a/templates/blog/talk.html.twig +++ b/templates/blog/talk.html.twig @@ -23,6 +23,18 @@ {% endfor %} {% endif %} +{% if widget_type == 'interview' or widget_type == 'all' %} + {% for question in questions %} + +

{{ question.question }}

+ + + +

{{ question.reponse }}

+ + {% endfor %} +{% endif %} + {% if widget_type == 'speaker' or widget_type == 'all' %}

{% if talks_infos|length > 1 %}Des conférences présentées par{% else %}Une conférence présentée par{% endif %}

{% for speaker in speakers %} diff --git a/tests/behat/features/Admin/Events/InterviewErreurConfig.feature b/tests/behat/features/Admin/Events/InterviewErreurConfig.feature new file mode 100644 index 000000000..146ebb5ce --- /dev/null +++ b/tests/behat/features/Admin/Events/InterviewErreurConfig.feature @@ -0,0 +1,8 @@ +Feature: Administration - Évènements - Interviews - Erreur de config + + @reloadDbWithTestData + Scenario: La configuration WordPress est obligatoire + Given I am logged in as admin and on the Administration + And I follow "afup-main-menu-item--admin_event_interview_list" + Then the ".content h2" element should contain "Interviews" + And I should see "Configuration WordPress manquante" diff --git a/tests/behat/features/Admin/Events/Interviews.feature b/tests/behat/features/Admin/Events/Interviews.feature new file mode 100644 index 000000000..0e0dac86a --- /dev/null +++ b/tests/behat/features/Admin/Events/Interviews.feature @@ -0,0 +1,64 @@ +Feature: Administration - Évènements - Interviews + + Background: + Given I am logged in as admin and on the Administration + And I follow "afup-main-menu-item--admin_event_interview_list" + Then the ".content h2" element should contain "Interviews" + And I follow "Configurer" + When I select "1" from "interview_config[interviewsWordpressCategoryId]" + And I fill in "interview_config[interviewsIntro]" with "Les super interviews" + And I fill in "interview_config[interviewsCtaText]" with "Le bouton" + And I press "Enregistrer" + + @reloadDbWithTestData + Scenario: Lister les speakers planifiés sans interview + Given I follow "afup-main-menu-item--admin_event_interview_list" + Then I should not see "Configuration WordPress manquante" + And I should see "Nouvelle interview" + And I should see "Aucune interview pour le moment" + And I should see "Speakers sans interview" + And I should see "Geoffrey BACHELET" + And I should see "Adrien GALLOU" + + @reloadDbWithTestData + Scenario: Création d'une interview pour un speaker + And I follow "afup-main-menu-item--admin_event_interview_list" + And I am on "/admin/event/interviews/add" + Then the ".content h2" element should contain "Interview" + When I select "Geoffrey BACHELET" from "interview[speakers][]" + And I fill in "interview[datePublication]" with "2026-06-01T10:00:00" + And I fill in "interview[questions][0][question]" with "Quelle est la différence entre un pigeon ?" + And I fill in "interview[questions][0][reponse]" with "Aucune. Les deux pattes ont la même taille, surtout la gauche." + And I press "Enregistrer" + Then I should see "L'interview a été enregistrée" + And the ".content table" element should contain "Geoffrey BACHELET" + And I should see "01/06/2026" + + Given I am on "/blog/talk_widget?ids=1&type=interview" + Then the response should contain "Quelle est la différence entre un pigeon ?" + And the response should contain "Aucune. Les deux pattes ont la même taille, surtout la gauche." + + @reloadDbWithTestData + Scenario: Création d'une interview groupant plusieurs speakers + And I follow "afup-main-menu-item--admin_event_interview_list" + And I follow "Nouvelle interview" + And I fill in "interview[datePublication]" with "2026-06-01T10:00:00" + When I select "Geoffrey BACHELET" from "interview[speakers][]" + And I additionally select "Adrien GALLOU" from "interview[speakers][]" + And I fill in "interview[questions][0][question]" with "Où est-elle ?" + And I fill in "interview[questions][0][reponse]" with "Ici." + And I press "Enregistrer" + Then I should see "L'interview a été enregistrée" + And the ".content table" element should contain "Adrien GALLOU, Geoffrey BACHELET" + + @reloadDbWithTestData + Scenario: Acces refusé pour un membre simple + Given I am logged-in with the user "paul" and the password "paul" + And I am on "/admin/event/interviews" + Then the response status code should be 403 + + @reloadDbWithTestData + Scenario: Interview inexistante + Given I am logged in as admin and on the Administration + And I am on "/admin/event/interviews/9999" + Then the response status code should be 404 diff --git a/tests/support/Wordpress/FakeWordpressClient.php b/tests/support/Wordpress/FakeWordpressClient.php new file mode 100644 index 000000000..7257f38da --- /dev/null +++ b/tests/support/Wordpress/FakeWordpressClient.php @@ -0,0 +1,25 @@ +