Parcourir la source

async export now creates a file record before the file generation

Olivier Massot il y a 3 ans
Parent
commit
70a18982ad

+ 1 - 1
.env

@@ -78,5 +78,5 @@ MERCURE_URL=https://mercure.opentalent.fr/.well-known/mercure
 # The public URL of the Mercure hub, used by the browser to connect
 MERCURE_PUBLIC_URL=https://mercure.opentalent.fr/.well-known/mercure
 # The secret used to sign the JWTs
-MERCURE_JWT_SECRET="!ChangeMe!"
+MERCURE_JWT_SECRET="8Kimwk38Kpv@JN04vE,iXM"
 ###< symfony/mercure-bundle ###

+ 8 - 0
readme.md

@@ -2,3 +2,11 @@
 
 [![pipeline status](http://gitlab.2iopenservice.com/opentalent/api/badges/master/pipeline.svg)](http://gitlab.2iopenservice.com/opentalent/api/-/commits/master)
 [![coverage report](http://gitlab.2iopenservice.com/opentalent/api/badges/master/coverage.svg)](http://gitlab.2iopenservice.com/opentalent/api/-/commits/master)
+
+## Démarrer le handler de messenger
+
+Pour consommer les signaux messengers, lancer :
+
+    php bin/console messenger:consume async
+
+> Voir: <https://symfony.com/doc/5.4/messenger.html#consuming-messages-running-the-worker>

+ 22 - 0
src/ApiResources/Export/ExportRequest.php

@@ -42,6 +42,12 @@ abstract class ExportRequest
      */
     protected bool $async = true;
 
+    /**
+     * The id of the db record of the file
+     * @var int|null
+     */
+    protected ?int $fileId = null;
+
     /**
      * @return int
      */
@@ -97,4 +103,20 @@ abstract class ExportRequest
     {
         $this->async = $async;
     }
+
+    /**
+     * @return int|null
+     */
+    public function getFileId(): ?int
+    {
+        return $this->fileId;
+    }
+
+    /**
+     * @param int|null $fileId
+     */
+    public function setFileId(?int $fileId): void
+    {
+        $this->fileId = $fileId;
+    }
 }

+ 2 - 4
src/ApiResources/Export/LicenceCmf/LicenceCmfOrganizationER.php

@@ -18,11 +18,9 @@ use Symfony\Component\Validator\Constraints as Assert;
 #[ApiResource(
     collectionOperations: [
         'post' => [
-            'security' => '(is_granted("ROLE_ADMIN_CORE") or 
-                            is_granted("ROLE_ADMINISTRATIF_MANAGER_CORE") or 
-                           ) and object.getOrganizationId() == user.getOrganization().getId()',
+            'security' => '(is_granted("ROLE_ADMIN_CORE") or is_granted("ROLE_ADMINISTRATIF_MANAGER_CORE"))',
             'method' => 'POST',
-            'path' => '/licence-cmf/organization',
+            'path' => '/cmf-licence/organization',
         ],
     ],
     routePrefix: '/export'

+ 27 - 12
src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php

@@ -6,8 +6,11 @@ namespace App\DataPersister\Export\LicenceCmf;
 use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Enum\Core\FileStatusEnum;
 use App\Message\Command\Export;
 use App\Service\ServiceIterator\ExporterIterator;
+use Doctrine\ORM\EntityManagerInterface;
 use Exception;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Security\Core\Security;
@@ -16,9 +19,10 @@ use Symfony\Component\HttpFoundation\Response;
 class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
 {
     public function __construct(
-        private Security $security,
-        private MessageBusInterface $messageBus,
-        private ExporterIterator $handler
+        private EntityManagerInterface $em,
+        private Security               $security,
+        private MessageBusInterface    $messageBus,
+        private ExporterIterator       $handler
     ) {}
 
     public function supports($data, array $context = []): bool
@@ -38,21 +42,32 @@ class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
         $access = $this->security->getUser();
         $exportRequest->setRequesterId($access->getId());
 
-        if ($exportRequest->isAsync()) {
+        $file = new File();
+        $file->setOrganization($access->getOrganization());
+        $file->setVisibility('NOBODY');
+        $file->setFolder('DOCUMENTS');
+        $file->setCreateDate(new \DateTime());
+        $file->setCreatedBy($access->getId());
+        $file->setStatus(FileStatusEnum::PENDING()->getValue());
+        $file->setName('');
+        $file->setPath('');
+        $file->setSlug('');
+        $this->em->persist($file);
+        $this->em->flush();
 
-            // Send the export request to Messenger (@see App\Message\Handler\ExportHandler)
-            $this->messageBus->dispatch(
-                new Export($exportRequest)
-            );
-            return new Response(null, 204);
-
-        } else {
+        $exportRequest->setFileId($file->getId());
 
+        if (!$exportRequest->isAsync()) {
             $exportService = $this->handler->getExporterFor($exportRequest);
             $file = $exportService->export($exportRequest);
-
             return new Response('File generated: ' . $file->getId(), 200);
         }
+
+        // Send the export request to Messenger (@see App\Message\Handler\ExportHandler)
+        $this->messageBus->dispatch(
+            new Export($exportRequest)
+        );
+        return new Response('Export request has been received (file id: ' . $file->getId() . ')', 200);
     }
 
     /**

+ 26 - 0
src/Entity/Core/File.php

@@ -12,6 +12,8 @@ use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use JetBrains\PhpStorm\Pure;
+use Symfony\Component\Validator\Constraints as Assert;
+use App\Enum\Core\FileStatusEnum;
 
 #[ApiResource(
     collectionOperations: [],
@@ -144,6 +146,14 @@ class File
     #[ORM\Column]
     private ?int $updatedBy;
 
+    /**
+     * Statut du fichier (en cours de génération, prêt, supprimé, etc.)
+     * @var string | null
+     */
+    #[ORM\Column(length: 50)]
+    #[Assert\Choice(callback: [FileStatusEnum::class, 'toArray'])]
+    private ?string $status = null;
+
 //    #[ORM\Column]
 //    private ?int $eventReport_id;
 //
@@ -442,6 +452,22 @@ class File
         $this->updatedBy = $updatedBy;
     }
 
+    /**
+     * @return string|null
+     */
+    public function getStatus(): ?string
+    {
+        return $this->status;
+    }
+
+    /**
+     * @param string|null $status
+     */
+    public function setStatus(?string $status): void
+    {
+        $this->status = $status;
+    }
+
     public function getOrganizationLogos(): Collection
     {
         return $this->organizationLogos;

+ 18 - 0
src/Enum/Core/FileStatusEnum.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Enum\Core;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Statuts des fichiers
+ * @method static PENDING()
+ * @method static READY()
+ */
+class FileStatusEnum extends Enum
+{
+    private const PENDING = 'PENDING';
+    private const READY = 'READY';
+    private const DELETED = 'DELETED';
+    private const ERROR = 'ERROR';
+}

+ 16 - 3
src/Message/Handler/ExportHandler.php

@@ -5,18 +5,31 @@ namespace App\Message\Handler;
 
 use App\Message\Command\Export;
 use App\Service\ServiceIterator\ExporterIterator;
+use Symfony\Component\Mercure\HubInterface;
+use Symfony\Component\Mercure\Update;
+use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
 use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
 
 class ExportHandler implements MessageHandlerInterface
 {
     public function __construct(
-        private ExporterIterator $handler
+        private ExporterIterator $handler,
+        private HubInterface $mercureHub
     ) {}
 
     public function __invoke(Export $export)
     {
         $exportRequest = $export->getExportRequest();
-        $exportService = $this->handler->getExporterFor($exportRequest);
-        $exportService->export($exportRequest);
+        try {
+            $exportService = $this->handler->getExporterFor($exportRequest);
+            $file = $exportService->export($exportRequest);
+
+            $update = new Update('files/' . $file->getId());
+            $this->mercureHub->publish($update);
+            
+        } catch (\Exception $e) {
+            // To prevent Messenger from retrying
+            throw new UnrecoverableMessageHandlingException($e->getMessage(), $e->getCode(), $e);
+        }
     }
 }

+ 39 - 24
src/Service/Export/BaseExporter.php

@@ -5,8 +5,10 @@ namespace App\Service\Export;
 
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Core\File;
+use App\Enum\Core\FileStatusEnum;
 use App\Enum\Export\ExportFormatEnum;
 use App\Repository\Access\AccessRepository;
+use App\Repository\Core\FileRepository;
 use App\Service\Export\Model\ExportModelInterface;
 use App\Service\ServiceIterator\EncoderIterator;
 use App\Service\Storage\TemporaryFileStorage;
@@ -24,6 +26,7 @@ abstract class BaseExporter implements ExporterInterface
 {
     // dependencies injections
     protected AccessRepository $accessRepository;
+    protected FileRepository $fileRepository;
     protected Environment $twig;
     protected EncoderIterator $encoderIterator;
     protected EntityManagerInterface $entityManager;
@@ -31,17 +34,26 @@ abstract class BaseExporter implements ExporterInterface
     protected LoggerInterface $logger;
 
     #[Required]
-    public function setAccessRepository(AccessRepository $accessRepository) { $this->accessRepository = $accessRepository; }
+    public function setAccessRepository(AccessRepository $accessRepository): void
+    { $this->accessRepository = $accessRepository; }
     #[Required]
-    public function setTwig(Environment $twig) { $this->twig = $twig; }
+    public function setFileRepository(FileRepository $fileRepository): void
+    { $this->fileRepository = $fileRepository; }
     #[Required]
-    public function setEncoderIterator(EncoderIterator $encoderIterator) { $this->encoderIterator = $encoderIterator; }
+    public function setTwig(Environment $twig): void
+    { $this->twig = $twig; }
     #[Required]
-    public function setEntityManager(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; }
+    public function setEncoderIterator(EncoderIterator $encoderIterator): void
+    { $this->encoderIterator = $encoderIterator; }
     #[Required]
-    public function setStorage(TemporaryFileStorage $storage) { $this->storage = $storage; }
+    public function setEntityManager(EntityManagerInterface $entityManager): void
+    { $this->entityManager = $entityManager; }
     #[Required]
-    public function setLogger(LoggerInterface $logger) { $this->logger = $logger; }
+    public function setStorage(TemporaryFileStorage $storage): void
+    { $this->storage = $storage; }
+    #[Required]
+    public function setLogger(LoggerInterface $logger): void
+    { $this->logger = $logger; }
 
     public function support(ExportRequest $exportRequest): bool
     {
@@ -75,26 +87,30 @@ abstract class BaseExporter implements ExporterInterface
 
         $path = $this->store($filename, $content);
 
-        // Met à jour l'enregistrement du fichier en base
-        // <-- [refactoring] cette partie pourrait être faite en amont du service
-        $file = new File();
-
-        $requesterId = $exportRequest->getRequesterId();
-        $organization = $this->accessRepository->find($requesterId)->getOrganization();
+        // Met à jour ou créé l'enregistrement du fichier en base
+        if ($exportRequest->getFileId() !== null) {
+            $file = $this->fileRepository->find($exportRequest->getFileId());
+        } else {
+            $file = new File();
+
+            $requesterId = $exportRequest->getRequesterId();
+            $organization = $this->accessRepository->find($requesterId)?->getOrganization();
+            if ($organization === null) {
+                throw new \RuntimeException('Unable to determine the organization of the curent user; abort.');
+            }
+            $file->setOrganization($organization);
+            $file->setVisibility('NOBODY');
+            $file->setFolder('DOCUMENTS');
+            $file->setCreateDate(new \DateTime());
+            $file->setCreatedBy($requesterId);
+        }
 
-        $file->setOrganization($organization);
-        $file->setVisibility('NOBODY');
-        $file->setFolder('DOCUMENTS');
-        $file->setCreateDate(new \DateTime());
-        $file->setCreatedBy($requesterId);
-        // -->
-        // <-- [refactoring] cette partie doit être faite après la création du fichier (storage ? service ?)
         $file->setType($this->getFileType());
         $file->setMimeType(ExportFormatEnum::getMimeType($exportRequest->getFormat()));
         $file->setName($filename);
         $file->setPath($path);
         $file->setSlug($path);
-        // -->
+        $file->setStatus(FileStatusEnum::READY()->getValue());
 
         $this->entityManager->persist($file);
         $this->entityManager->flush();
@@ -112,7 +128,7 @@ abstract class BaseExporter implements ExporterInterface
      */
     protected function buildModel(ExportRequest $exportRequest): ExportModelInterface
     {
-        throw new Exception('not implemented error');
+        throw new \RuntimeException('not implemented error');
     }
 
     /**
@@ -128,7 +144,7 @@ abstract class BaseExporter implements ExporterInterface
         $classname = end($arr);
         return StringsUtils::camelToSnake(
             preg_replace(
-                '/^([\w\d]+)Exporter$/',
+                '/^([\w]+)Exporter$/',
                 '$1',
                 $classname,
                 1)
@@ -173,8 +189,7 @@ abstract class BaseExporter implements ExporterInterface
      */
     protected function encode(string $html, string $format): string
     {
-        $encoder = $this->encoderIterator->getEncoderFor($format);
-        return $encoder->encode($html);
+        return $this->encoderIterator->getEncoderFor($format)->encode($html);
     }
 
     /**

+ 2 - 1
src/Service/Export/ExporterInterface.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace App\Service\Export;
 
 use App\ApiResources\Export\ExportRequest;
+use App\Entity\Core\File;
 
 /**
  * Classe de base des services d'export
@@ -24,5 +25,5 @@ interface ExporterInterface
      *
      * @param ExportRequest $exportRequest
      */
-    public function export(ExportRequest $exportRequest);
+    public function export(ExportRequest $exportRequest): File;
 }

+ 14 - 8
src/Service/Export/LicenceCmfExporter.php

@@ -15,15 +15,15 @@ use App\Service\Storage\UploadStorage;
 /**
  * Exporte la licence CMF de la structure ou du ou des access, au format demandé
  */
-class LicenceCmfExporter extends BaseExporter implements ExporterInterface
+class LicenceCmfExporter extends BaseExporter
 {
-    const CMF_ID = 12097;
+    public const CMF_ID = 12097;
 
     /**
      * La couleur de la carte de licence change chaque année, de manière cyclique
      */
-    const LICENCE_CMF_COLOR_START_YEAR = "2020";
-    const LICENCE_CMF_COLOR = [0 => '931572', 1 => 'C2981A', 2 =>  '003882', 3 =>  '27AAE1', 4 =>  '2BB673'];
+    public const LICENCE_CMF_COLOR_START_YEAR = "2020";
+    public const LICENCE_CMF_COLOR = [0 => '931572', 1 => 'C2981A', 2 =>  '003882', 3 =>  '27AAE1', 4 =>  '2BB673'];
 
     public function __construct(
         private OrganizationRepository $organizationRepository,
@@ -38,7 +38,10 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
 
     protected function buildModel(ExportRequest $exportRequest): LicenceCmfCollection
     {
-        $organization = $this->accessRepository->find($exportRequest->getRequesterId())->getOrganization();
+        $organization = $this->accessRepository->find($exportRequest->getRequesterId())?->getOrganization();
+        if ($organization === null) {
+            throw new \RuntimeException('Unable to dermin the organization of the curent user; abort.');
+        }
 
         $licenceCmf = new LicenceCmf();
         $licenceCmf->setId($organization->getId());
@@ -47,8 +50,10 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
         $licenceCmf->setOrganizationName($organization->getName());
         $licenceCmf->setOrganizationIdentifier($organization->getIdentifier());
 
-        $parentFederation = $organization->getNetworkOrganizations()->get(0)->getParent();
-        $licenceCmf->setFederationName($parentFederation->getName());
+        $parentFederation = $organization->getNetworkOrganizations()->get(0)?->getParent();
+        if ($parentFederation !== null) {
+            $licenceCmf->setFederationName($parentFederation->getName());
+        }
 
         $licenceCmf->setColor(
             $this->getLicenceColor($exportRequest->getYear())
@@ -71,6 +76,7 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
         }
 
         $cmf = $this->organizationRepository->find(self::CMF_ID);
+        /** @noinspection NullPointerExceptionInspection */
         $qrCodeId = $cmf->getParameters()?->getQrCode()?->getId();
         if ($qrCodeId) {
             $licenceCmf->setQrCodeUri(
@@ -108,7 +114,7 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
      * @return string
      */
     protected function getLicenceColor(int $year): string {
-        if (!self::LICENCE_CMF_COLOR_START_YEAR > $year) {
+        if (!(self::LICENCE_CMF_COLOR_START_YEAR > $year)) {
             return self::LICENCE_CMF_COLOR[0];
         }
         return self::LICENCE_CMF_COLOR[($year - self::LICENCE_CMF_COLOR_START_YEAR) % count(self::LICENCE_CMF_COLOR)];