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])
Comment thread
Mopolo marked this conversation as resolved.
->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