Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions app/config/packages/backoffice_menu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 6 additions & 0 deletions app/config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions app/config/packages/http_client.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions app/config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
20 changes: 20 additions & 0 deletions app/config/routing/admin_event.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions app/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
3 changes: 3 additions & 0 deletions app/config/services_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
59 changes: 59 additions & 0 deletions db/migrations/20260630120000_create_interview_tables.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

final class CreateInterviewTables extends AbstractMigration
{
public function change(): void
{
$this->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])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attention, l'unicité porte uniquement sur speaker_id, sans notion d'événement.

Du coup un speaker ne peut être rattaché à une seule interview, alors que la description dit "une personne ne peut être que dans une seule interview par évènement".

Je me trompe peut être mais j'ai l'impression qu'il y a un souci avec le modèle de donnée.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah oui c'est normal ça.

Dans la table des speakers (afup_conferenciers) il y a l'id de l'event (id_forum). Quand une personne est speaker à plusieurs events, il y a plusieurs lignes dans la table des speakers.

Donc pas besoin de refaire un lien vers l'event, on l'a via le speaker.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oui je suis d'accord avec toi, pas besoin de refaire le lien mais la contrainte d'unicité n'est pas bonne car uniquement sur la colonne speaker_id.

Si un speaker est interviewé pour un premier event, on ne pourra pas rajouter d'interview pour un évènement suivant.

A priori il faudrait mettre la contrainte d'unicité sur interview_id et speaker_id. Je me trompe ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dans notre modèle de données, un speaker_id ne peut pas être dans plusieurs events.

Si je soumet au CFP du Forum 2025 et au Forum 2026, j'aurais 2 speaker_id différents, un par Forum. Avec à chaque fois les données dupliquées (pour garder l'historique). Et du coup pas de soucis pour avoir une interview par event, vu que j'aurais un speaker_id différent pour chaque event.

Donc un speaker_id ne peut pas, et ne doit pas, être présent plusieurs fois dans la table interview_speaker. Avoir la contrainte sur la pair interview_id/speaker_id aurait le même effet en pratique mais aide moins à la perf quand on cherche une interview par speaker_id.

Est-ce que c'est plus clair ?

->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();
}
}
54 changes: 0 additions & 54 deletions phpstan-baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace AppBundle\Controller\Admin\Event\Interview;

use AppBundle\Event\AdminEventSelection;
use AppBundle\Event\Entity\Interview;
use AppBundle\Event\Entity\InterviewQuestion;
use AppBundle\Event\Entity\Repository\InterviewRepository;
use AppBundle\Event\Entity\Repository\SpeakerRepository as DoctrineSpeakerRepository;
use AppBundle\Event\Entity\Speaker as DoctrineSpeaker;
use AppBundle\Event\Form\InterviewType;
use AppBundle\Event\Model\Event;
use AppBundle\Event\Model\Repository\SpeakerRepository as TingSpeakerRepository;
use AppBundle\Event\Model\Repository\TalkRepository;
use AppBundle\Event\Model\Speaker as TingSpeaker;
use AppBundle\Event\Model\Talk;
use AppBundle\Event\Wordpress\WordpressClient;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class AddOrEditAction extends AbstractController
{
public function __construct(
private readonly TingSpeakerRepository $tingSpeakerRepository,
private readonly DoctrineSpeakerRepository $doctrineSpeakerRepository,
private readonly InterviewRepository $interviewRepository,
private readonly TalkRepository $talkRepository,
private readonly WordpressClient $wordpressClient,
private readonly LoggerInterface $logger,
) {}

public function __invoke(Request $request, AdminEventSelection $eventSelection, ?int $interviewId = null): Response
{
$event = $eventSelection->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<string, DoctrineSpeaker>
*/
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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

j'allais dire qu'il y avait peut-être un risque d'envoyer des informations qui ne devraient pas être affichées (secrétaire ou autre) en catchant tout \Exception et renvoyant le message, mais vu le public qui utilisera la fonctionnalité ça ne devrait pas être gênant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm oui ça me paraissait ok. J'ai rajouté un log pour avoir une trace si besoin de debug plus tard.

$this->logger->error('Erreur WordPress : ' . $e->getMessage());

return false;
}
}
}
Loading
Loading