Add organizer logo upload, Meilisearch organizer search, and webp URL rewriting
VichUploader organizer logo: - Add organizer_logo mapping with local Flysystem storage - Add logoFile, logoName, updatedAt fields to User entity - Use Vich Attribute (not deprecated Annotation) - Add migration for logo_name and updated_at columns Meilisearch organizer search: - Add search bar on /admin/organisateurs page (hides tabs during search) - Index organizers in Meilisearch on approval - Sync button on dashboard now syncs both buyers and organizers - Add tests: search query, search error Liip Imagine webp: - Add format filter to all filter_sets for explicit webp conversion - Add organizer_logo filter_set (400x400, webp) - Create WebpExtensionSubscriber to rewrite image URLs to .webp extension - 8 tests for subscriber (png, jpg, jpeg, gif, webp passthrough, case insensitive, null) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,8 @@ flysystem:
|
||||
client: 's3_client'
|
||||
bucket: '%env(S3_BUCKET)%'
|
||||
prefix: 'uploads'
|
||||
|
||||
logos.storage:
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%kernel.project_dir%/public/uploads/logos'
|
||||
|
||||
@@ -13,6 +13,7 @@ liip_imagine:
|
||||
format: webp
|
||||
filters:
|
||||
thumbnail: { size: [200, 72], mode: inset }
|
||||
format: { format: 'webp' }
|
||||
|
||||
thumbnail:
|
||||
quality: 80
|
||||
@@ -20,15 +21,25 @@ liip_imagine:
|
||||
filters:
|
||||
thumbnail: { size: [300, 300], mode: inset }
|
||||
background: { size: [300, 300], position: center, color: '#ffffff' }
|
||||
format: { format: 'webp' }
|
||||
|
||||
medium:
|
||||
quality: 85
|
||||
format: webp
|
||||
filters:
|
||||
thumbnail: { size: [600, 600], mode: inset }
|
||||
format: { format: 'webp' }
|
||||
|
||||
large:
|
||||
quality: 90
|
||||
format: webp
|
||||
filters:
|
||||
thumbnail: { size: [1200, 1200], mode: inset }
|
||||
format: { format: 'webp' }
|
||||
|
||||
organizer_logo:
|
||||
quality: 85
|
||||
format: webp
|
||||
filters:
|
||||
thumbnail: { size: [400, 400], mode: inset }
|
||||
format: { format: 'webp' }
|
||||
|
||||
@@ -13,3 +13,8 @@ vich_uploader:
|
||||
service: Vich\UploaderBundle\Naming\CurrentDateDirectoryNamer
|
||||
options:
|
||||
date_time_format: 'Y/m'
|
||||
|
||||
organizer_logo:
|
||||
uri_prefix: /uploads/logos
|
||||
upload_destination: logos.storage
|
||||
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
|
||||
|
||||
33
migrations/Version20260319143558.php
Normal file
33
migrations/Version20260319143558.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260319143558 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" ADD logo_name VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" DROP logo_name');
|
||||
$this->addSql('ALTER TABLE "user" DROP updated_at');
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,25 @@ class AdminController extends AbstractController
|
||||
$meilisearch->addDocuments('buyers', $documents);
|
||||
}
|
||||
|
||||
$this->addFlash('success', sprintf('%d acheteur(s) synchronise(s) dans Meilisearch.', \count($documents)));
|
||||
$organizers = array_filter($allUsers, fn (User $u) => $u->isApproved() && \in_array('ROLE_ORGANIZER', $u->getRoles(), true));
|
||||
|
||||
$meilisearch->createIndexIfNotExists('organizers');
|
||||
|
||||
$orgaDocs = array_map(fn (User $u) => [
|
||||
'id' => $u->getId(),
|
||||
'firstName' => $u->getFirstName(),
|
||||
'lastName' => $u->getLastName(),
|
||||
'email' => $u->getEmail(),
|
||||
'companyName' => $u->getCompanyName(),
|
||||
'siret' => $u->getSiret(),
|
||||
'city' => $u->getCity(),
|
||||
], array_values($organizers));
|
||||
|
||||
if ([] !== $orgaDocs) {
|
||||
$meilisearch->addDocuments('organizers', $orgaDocs);
|
||||
}
|
||||
|
||||
$this->addFlash('success', sprintf('%d acheteur(s) et %d organisateur(s) synchronise(s) dans Meilisearch.', \count($documents), \count($orgaDocs)));
|
||||
|
||||
return $this->redirectToRoute('app_admin_dashboard');
|
||||
}
|
||||
@@ -97,17 +115,37 @@ class AdminController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/organisateurs', name: 'app_admin_organizers')]
|
||||
public function organizers(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
|
||||
public function organizers(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator, MeilisearchService $meilisearch): Response
|
||||
{
|
||||
$query = $request->query->getString('q', '');
|
||||
$tab = $request->query->getString('tab', 'pending');
|
||||
$searchResults = null;
|
||||
|
||||
if ('' !== $query) {
|
||||
try {
|
||||
$searchResults = $meilisearch->search('organizers', $query, ['limit' => 50]);
|
||||
} catch (\Throwable) {
|
||||
$this->addFlash('error', 'Erreur de recherche Meilisearch.');
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $searchResults && isset($searchResults['hits'])) {
|
||||
$hitIds = array_map(fn (array $hit) => $hit['id'], $searchResults['hits']);
|
||||
$organizers = $em->getRepository(User::class)->findBy(['id' => $hitIds]);
|
||||
} else {
|
||||
$allUsers = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']);
|
||||
$organizers = array_values(array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true)));
|
||||
}
|
||||
|
||||
$tab = $request->query->getString('tab', 'pending');
|
||||
if ('' === $query) {
|
||||
if ('approved' === $tab) {
|
||||
$filtered = array_values(array_filter($organizers, fn (User $u) => $u->isApproved()));
|
||||
} else {
|
||||
$filtered = array_values(array_filter($organizers, fn (User $u) => !$u->isApproved()));
|
||||
}
|
||||
} else {
|
||||
$filtered = $organizers;
|
||||
}
|
||||
|
||||
$pagination = $paginator->paginate(
|
||||
$filtered,
|
||||
@@ -118,6 +156,7 @@ class AdminController extends AbstractController
|
||||
return $this->render('admin/organizers.html.twig', [
|
||||
'organizers' => $pagination,
|
||||
'tab' => $tab,
|
||||
'query' => $query,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -251,11 +290,22 @@ class AdminController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/organisateur/{id}/approuver', name: 'app_admin_approve_organizer', methods: ['POST'])]
|
||||
public function approveOrganizer(User $user, EntityManagerInterface $em, MailerService $mailerService): Response
|
||||
public function approveOrganizer(User $user, EntityManagerInterface $em, MailerService $mailerService, MeilisearchService $meilisearch): Response
|
||||
{
|
||||
$user->setIsApproved(true);
|
||||
$em->flush();
|
||||
|
||||
$meilisearch->createIndexIfNotExists('organizers');
|
||||
$meilisearch->addDocuments('organizers', [[
|
||||
'id' => $user->getId(),
|
||||
'firstName' => $user->getFirstName(),
|
||||
'lastName' => $user->getLastName(),
|
||||
'email' => $user->getEmail(),
|
||||
'companyName' => $user->getCompanyName(),
|
||||
'siret' => $user->getSiret(),
|
||||
'city' => $user->getCity(),
|
||||
]]);
|
||||
|
||||
$loginUrl = $this->generateUrl('app_login', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$mailerService->sendEmail(
|
||||
|
||||
@@ -5,12 +5,15 @@ namespace App\Entity;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Vich\UploaderBundle\Mapping\Attribute as Vich;
|
||||
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
#[UniqueEntity(fields: ['email'], message: 'Un compte existe déjà avec cet email.')]
|
||||
#[Vich\Uploadable]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -55,6 +58,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[Vich\UploadableField(mapping: 'organizer_logo', fileNameProperty: 'logoName')]
|
||||
private ?File $logoFile = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $logoName = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
#[ORM\Column(length: 6, nullable: true)]
|
||||
private ?string $resetCode = null;
|
||||
|
||||
@@ -236,6 +248,39 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLogoFile(): ?File
|
||||
{
|
||||
return $this->logoFile;
|
||||
}
|
||||
|
||||
public function setLogoFile(?File $logoFile = null): static
|
||||
{
|
||||
$this->logoFile = $logoFile;
|
||||
|
||||
if (null !== $logoFile) {
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLogoName(): ?string
|
||||
{
|
||||
return $this->logoName;
|
||||
}
|
||||
|
||||
public function setLogoName(?string $logoName): static
|
||||
{
|
||||
$this->logoName = $logoName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function getResetCode(): ?string
|
||||
{
|
||||
return $this->resetCode;
|
||||
|
||||
27
src/EventSubscriber/WebpExtensionSubscriber.php
Normal file
27
src/EventSubscriber/WebpExtensionSubscriber.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Liip\ImagineBundle\Events\CacheResolveEvent;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
class WebpExtensionSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'liip_imagine.post_resolve' => 'onPostResolve',
|
||||
];
|
||||
}
|
||||
|
||||
public function onPostResolve(CacheResolveEvent $event): void
|
||||
{
|
||||
$url = $event->getUrl();
|
||||
|
||||
if (null === $url) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setUrl(preg_replace('/\.(png|jpg|jpeg|gif|bmp|tiff)$/i', '.webp', $url));
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,28 @@
|
||||
<p class="font-bold text-gray-500 italic">{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.</p>
|
||||
</div>
|
||||
|
||||
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;margin-bottom:2rem;">
|
||||
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:1rem;">Rechercher</h2>
|
||||
<form method="get" action="{{ path('app_admin_organizers') }}" style="display:flex;gap:1rem;align-items:flex-end;">
|
||||
<div style="flex:1;">
|
||||
<input type="text" name="q" value="{{ query }}"
|
||||
style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;"
|
||||
placeholder="Rechercher par nom, email, raison sociale, SIRET...">
|
||||
</div>
|
||||
<input type="hidden" name="tab" value="{{ tab }}">
|
||||
<button type="submit" style="padding:0.5rem 1rem;border:2px solid #111827;background:#fabf04;cursor:pointer;white-space:nowrap;" class="font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Rechercher</button>
|
||||
{% if query %}
|
||||
<a href="{{ path('app_admin_organizers', {tab: tab}) }}" style="padding:0.5rem 1rem;border:2px solid #111827;background:white;white-space:nowrap;" class="font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Effacer</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if not query %}
|
||||
<div style="display:flex;gap:0;margin-bottom:2rem;">
|
||||
<a href="{{ path('app_admin_organizers', {tab: 'pending'}) }}" style="flex:1;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'pending' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-sm tracking-widest transition-all">En attente</a>
|
||||
<a href="{{ path('app_admin_organizers', {tab: 'approved'}) }}" style="flex:1;text-align:center;padding:0.75rem;border:3px solid #111827;{{ tab == 'approved' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-sm tracking-widest transition-all">Valides</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
|
||||
@@ -107,8 +107,7 @@ class AdminControllerTest extends WebTestCase
|
||||
$em->flush();
|
||||
|
||||
$meilisearch = $this->createMock(MeilisearchService::class);
|
||||
$meilisearch->expects(self::once())->method('createIndexIfNotExists');
|
||||
$meilisearch->expects(self::once())->method('addDocuments');
|
||||
$meilisearch->expects(self::exactly(2))->method('createIndexIfNotExists');
|
||||
static::getContainer()->set(MeilisearchService::class, $meilisearch);
|
||||
|
||||
$client->loginUser($admin);
|
||||
@@ -329,6 +328,39 @@ class AdminControllerTest extends WebTestCase
|
||||
self::assertNull($buyer->getEmailVerificationToken());
|
||||
}
|
||||
|
||||
public function testOrganizersSearchWithQuery(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$admin = $this->createUser(['ROLE_ROOT']);
|
||||
|
||||
$meilisearch = $this->createMock(MeilisearchService::class);
|
||||
$meilisearch->expects(self::once())->method('search')->with('organizers')->willReturn([
|
||||
'hits' => [],
|
||||
'estimatedTotalHits' => 0,
|
||||
]);
|
||||
static::getContainer()->set(MeilisearchService::class, $meilisearch);
|
||||
|
||||
$client->loginUser($admin);
|
||||
$client->request('GET', '/admin/organisateurs?q=test');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testOrganizersSearchWithError(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$admin = $this->createUser(['ROLE_ROOT']);
|
||||
|
||||
$meilisearch = $this->createMock(MeilisearchService::class);
|
||||
$meilisearch->method('search')->willThrowException(new \RuntimeException('Meilisearch down'));
|
||||
static::getContainer()->set(MeilisearchService::class, $meilisearch);
|
||||
|
||||
$client->loginUser($admin);
|
||||
$client->request('GET', '/admin/organisateurs?q=test');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testOrganizersPagePendingTab(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
@@ -363,6 +395,11 @@ class AdminControllerTest extends WebTestCase
|
||||
$mailer->expects(self::once())->method('sendEmail');
|
||||
static::getContainer()->set(MailerService::class, $mailer);
|
||||
|
||||
$meilisearch = $this->createMock(MeilisearchService::class);
|
||||
$meilisearch->expects(self::once())->method('createIndexIfNotExists')->with('organizers');
|
||||
$meilisearch->expects(self::once())->method('addDocuments');
|
||||
static::getContainer()->set(MeilisearchService::class, $meilisearch);
|
||||
|
||||
$client->loginUser($admin);
|
||||
$client->request('POST', '/admin/organisateur/'.$orga->getId().'/approuver');
|
||||
|
||||
|
||||
@@ -88,6 +88,33 @@ class UserTest extends TestCase
|
||||
self::assertNull($user->getPhone());
|
||||
}
|
||||
|
||||
public function testLogoFields(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertNull($user->getLogoFile());
|
||||
self::assertNull($user->getLogoName());
|
||||
self::assertNull($user->getUpdatedAt());
|
||||
|
||||
$result = $user->setLogoName('logo.png');
|
||||
self::assertSame($user, $result);
|
||||
self::assertSame('logo.png', $user->getLogoName());
|
||||
|
||||
$file = new \Symfony\Component\HttpFoundation\File\File(__FILE__);
|
||||
$result = $user->setLogoFile($file);
|
||||
self::assertSame($user, $result);
|
||||
self::assertSame($file, $user->getLogoFile());
|
||||
self::assertNotNull($user->getUpdatedAt());
|
||||
}
|
||||
|
||||
public function testSetLogoFileNullDoesNotUpdateTimestamp(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setLogoFile(null);
|
||||
|
||||
self::assertNull($user->getUpdatedAt());
|
||||
}
|
||||
|
||||
public function testResetCodeFields(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
81
tests/EventSubscriber/WebpExtensionSubscriberTest.php
Normal file
81
tests/EventSubscriber/WebpExtensionSubscriberTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\EventSubscriber\WebpExtensionSubscriber;
|
||||
use Liip\ImagineBundle\Events\CacheResolveEvent;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class WebpExtensionSubscriberTest extends TestCase
|
||||
{
|
||||
private WebpExtensionSubscriber $subscriber;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->subscriber = new WebpExtensionSubscriber();
|
||||
}
|
||||
|
||||
public function testGetSubscribedEvents(): void
|
||||
{
|
||||
$events = WebpExtensionSubscriber::getSubscribedEvents();
|
||||
|
||||
self::assertArrayHasKey('liip_imagine.post_resolve', $events);
|
||||
self::assertSame('onPostResolve', $events['liip_imagine.post_resolve']);
|
||||
}
|
||||
|
||||
public function testRewritesPngToWebp(): void
|
||||
{
|
||||
$event = new CacheResolveEvent('logo.png', 'thumbnail', '/media/cache/thumbnail/logo.png');
|
||||
$this->subscriber->onPostResolve($event);
|
||||
|
||||
self::assertSame('/media/cache/thumbnail/logo.webp', $event->getUrl());
|
||||
}
|
||||
|
||||
public function testRewritesJpgToWebp(): void
|
||||
{
|
||||
$event = new CacheResolveEvent('photo.jpg', 'medium', '/media/cache/medium/photo.jpg');
|
||||
$this->subscriber->onPostResolve($event);
|
||||
|
||||
self::assertSame('/media/cache/medium/photo.webp', $event->getUrl());
|
||||
}
|
||||
|
||||
public function testRewritesJpegToWebp(): void
|
||||
{
|
||||
$event = new CacheResolveEvent('image.jpeg', 'large', '/media/cache/large/image.jpeg');
|
||||
$this->subscriber->onPostResolve($event);
|
||||
|
||||
self::assertSame('/media/cache/large/image.webp', $event->getUrl());
|
||||
}
|
||||
|
||||
public function testRewritesGifToWebp(): void
|
||||
{
|
||||
$event = new CacheResolveEvent('anim.gif', 'thumbnail', '/media/cache/thumbnail/anim.gif');
|
||||
$this->subscriber->onPostResolve($event);
|
||||
|
||||
self::assertSame('/media/cache/thumbnail/anim.webp', $event->getUrl());
|
||||
}
|
||||
|
||||
public function testDoesNotRewriteWebp(): void
|
||||
{
|
||||
$event = new CacheResolveEvent('already.webp', 'thumbnail', '/media/cache/thumbnail/already.webp');
|
||||
$this->subscriber->onPostResolve($event);
|
||||
|
||||
self::assertSame('/media/cache/thumbnail/already.webp', $event->getUrl());
|
||||
}
|
||||
|
||||
public function testCaseInsensitive(): void
|
||||
{
|
||||
$event = new CacheResolveEvent('logo.PNG', 'thumbnail', '/media/cache/thumbnail/logo.PNG');
|
||||
$this->subscriber->onPostResolve($event);
|
||||
|
||||
self::assertSame('/media/cache/thumbnail/logo.webp', $event->getUrl());
|
||||
}
|
||||
|
||||
public function testNullUrlIsIgnored(): void
|
||||
{
|
||||
$event = new CacheResolveEvent('logo.png', 'thumbnail');
|
||||
$this->subscriber->onPostResolve($event);
|
||||
|
||||
self::assertNull($event->getUrl());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user