Переглянути джерело

Merge branch 'feature/structure_cmf_licence_page' into 'develop'

Feature/structure cmf licence page

See merge request opentalent/ap2i!3
Olivier Massot 3 роки тому
батько
коміт
3fcd7ff387
64 змінених файлів з 3292 додано та 261 видалено
  1. 15 0
      .env
  2. 25 20
      composer.json
  3. 516 162
      composer.lock
  4. 2 0
      config/bundles.php
  5. 1 1
      config/packages/doctrine.yaml
  6. 11 0
      config/packages/knp_gaufrette.yaml
  7. 9 0
      config/packages/knp_snappy.yaml
  8. 14 0
      config/packages/messenger.yaml
  9. 2 1
      config/packages/twig.yaml
  10. 8 1
      config/routes.yaml
  11. 14 3
      config/services.yaml
  12. 32 0
      doc/exports.md
  13. 27 35
      phpunit.xml.dist
  14. BIN
      public/static/ciseaux.png
  15. BIN
      public/static/cmf-reseau.png
  16. BIN
      public/static/cmf_licence.png
  17. BIN
      public/static/footer_report_activity.jpg
  18. BIN
      public/static/header_report_activity.jpg
  19. BIN
      public/static/logo_welcome.png
  20. BIN
      public/static/picto_face.png
  21. 100 0
      src/ApiResources/Export/ExportRequest.php
  22. 8 0
      src/ApiResources/Export/ExportRequestInterface.php
  23. 49 0
      src/ApiResources/Export/LicenceCmf/LicenceCmfOrganizationER.php
  24. 65 0
      src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php
  25. 4 3
      src/Doctrine/Access/CurrentAccessExtension.php
  26. 298 2
      src/Entity/Core/File.php
  27. 16 11
      src/Entity/Organization/Organization.php
  28. 20 0
      src/Entity/Organization/Parameters.php
  29. 39 0
      src/Enum/Export/ExportFormatEnum.php
  30. 33 0
      src/Message/Command/Export.php
  31. 22 0
      src/Message/Handler/ExportHandler.php
  32. 35 3
      src/Repository/Access/AccessRepository.php
  33. 4 3
      src/Service/Access/Utils.php
  34. 210 0
      src/Service/Export/BaseExporter.php
  35. 11 0
      src/Service/Export/Encoder/EncoderInterface.php
  36. 59 0
      src/Service/Export/Encoder/PdfEncoder.php
  37. 28 0
      src/Service/Export/ExporterInterface.php
  38. 116 0
      src/Service/Export/LicenceCmfExporter.php
  39. 6 0
      src/Service/Export/Model/ExportModelInterface.php
  40. 315 0
      src/Service/Export/Model/LicenceCmf.php
  41. 38 0
      src/Service/Export/Model/LicenceCmfCollection.php
  42. 6 3
      src/Service/ServiceIterator/CurrentAccessExtensionIterator.php
  43. 40 0
      src/Service/ServiceIterator/EncoderIterator.php
  44. 40 0
      src/Service/ServiceIterator/ExporterIterator.php
  45. 4 3
      src/Service/ServiceIterator/OptionalsRolesIterator.php
  46. 22 0
      src/Service/Storage/FileStorage.php
  47. 43 0
      src/Service/Storage/TemporaryFileStorage.php
  48. 31 0
      src/Service/Storage/UploadStorage.php
  49. 46 0
      src/Service/Utils/Path.php
  50. 19 1
      src/Service/Utils/StringsUtils.php
  51. 51 0
      symfony.lock
  52. 348 0
      templates/export/licence_cmf.html.twig
  53. 2 2
      tests/Service/Access/AccessProfileCreatorTest.php
  54. 5 5
      tests/Service/Access/UtilsTest.php
  55. 1 1
      tests/Service/Dolibarr/DolibarrServiceTest.php
  56. 39 0
      tests/Service/Export/Encoder/PdfEncoderTest.php
  57. 167 0
      tests/Service/Export/LicenceCmfExporterTest.php
  58. 55 0
      tests/Service/ServiceIterator/CurrentAccessExtensionIteratorTest.php
  59. 45 0
      tests/Service/ServiceIterator/EncoderIteratorTest.php
  60. 56 0
      tests/Service/ServiceIterator/ExporterIteratorTest.php
  61. 45 0
      tests/Service/ServiceIterator/OptionalsRolesIteratorTest.php
  62. 34 0
      tests/Service/Storage/TemporaryFileStorageTest.php
  63. 31 0
      tests/Service/Utils/PathTest.php
  64. 10 1
      tests/Service/Utils/StringsUtilsTest.php

+ 15 - 0
.env

@@ -20,6 +20,9 @@ APP_SECRET=6a76497c8658bb23e2236f97a2627df3
 #TRUSTED_HOSTS='^(localhost|example\.com)$'
 ###< symfony/framework-bundle ###
 
+###> files management ###
+INTERNAL_FILES_DOWNLOAD_URI=https://api.opentalent.fr/_internal/secure/files
+###< files management ###
 
 ###> doctrine/doctrine-bundle ###
 # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
@@ -52,6 +55,18 @@ DOLIBARR_API_TOKEN='Bocc4zC0J186v8J6QCqu7DnoIw4I7mCJ'
 MOBYT_API_BASE_URI='https://app.mobyt.fr/API/v1.0/REST/'
 ###< mobyt client ###
 
+###> knplabs/knp-snappy-bundle ###
+WKHTMLTOPDF_PATH=/usr/local/bin/wkhtmltopdf
+WKHTMLTOIMAGE_PATH=/usr/local/bin/wkhtmltoimage
+###< knplabs/knp-snappy-bundle ###
+
+###> symfony/messenger ###
+# Choose one of the transports below
+MESSENGER_TRANSPORT_DSN=doctrine://default
+# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
+# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
+###< symfony/messenger ###
+
 ###> AdminAssos configuration ###
 #DATABASE_ADMINASSOS_URL=mysql://root:mysql660@db:3306/adminassos?serverVersion=5.7
 ###< AdminAssos configuration ###

+ 25 - 20
composer.json

@@ -14,42 +14,47 @@
         "api-platform/core": "^2.6",
         "blackfire/php-sdk": "^1.23",
         "composer/package-versions-deprecated": "^1.11",
+        "doctrine/dbal": "^2.6",
         "doctrine/doctrine-bundle": "^2.1",
         "doctrine/doctrine-migrations-bundle": "^3.0",
         "doctrine/orm": "^2.9",
         "egulias/email-validator": "^3.0",
         "jbouzekri/phumbor-bundle": "^2.1",
+        "knplabs/knp-gaufrette-bundle": "^0.7.1",
+        "knplabs/knp-snappy-bundle": "^1.9",
         "lexik/jwt-authentication-bundle": "^2.8",
         "myclabs/php-enum": "^1.7",
         "nelmio/cors-bundle": "^2.1",
         "odolbeau/phone-number-bundle": "^3.1",
         "phpdocumentor/reflection-docblock": "^5.2",
-        "symfony/asset": "5.3.*",
-        "symfony/console": "5.3.*",
-        "symfony/dotenv": "5.3.*",
-        "symfony/expression-language": "5.3.*",
+        "ramsey/uuid": "^4.2",
+        "symfony/asset": "5.4.*",
+        "symfony/console": "5.4.*",
+        "symfony/doctrine-messenger": "5.4.*",
+        "symfony/dotenv": "5.4.*",
+        "symfony/expression-language": "5.4.*",
         "symfony/flex": "^1.3.1",
-        "symfony/framework-bundle": "5.3.*",
-        "symfony/http-client": "5.3.*",
-        "symfony/intl": "5.3.*",
-        "symfony/monolog-bundle": "^3.0",
-        "symfony/property-access": "5.3.*",
-        "symfony/property-info": "5.3.*",
-        "symfony/security-bundle": "5.3.*",
-        "symfony/serializer": "5.3.*",
-        "symfony/twig-bundle": "^5.3",
-        "symfony/validator": "5.3.*",
-        "symfony/yaml": "5.3.*",
+        "symfony/framework-bundle": "5.4.*",
+        "symfony/http-client": "5.4.*",
+        "symfony/intl": "5.4.*",
+        "symfony/monolog-bundle": "^3.7",
+        "symfony/property-access": "5.4.*",
+        "symfony/property-info": "5.4.*",
+        "symfony/security-bundle": "5.4.*",
+        "symfony/serializer": "5.4.*",
+        "symfony/twig-bundle": "^5.4",
+        "symfony/validator": "5.4.*",
+        "symfony/yaml": "5.4.*",
         "vincent/foselastica": "1.2",
         "webonyx/graphql-php": "^14.3"
     },
     "require-dev": {
         "cyclonedx/cyclonedx-php-composer": "^3.4",
-        "symfony/debug-bundle": "5.3.*",
+        "symfony/debug-bundle": "5.4.*",
         "symfony/maker-bundle": "^1.21",
-        "symfony/phpunit-bridge": "^5.3",
-        "symfony/stopwatch": "^5.3",
-        "symfony/web-profiler-bundle": "^5.3"
+        "symfony/phpunit-bridge": "^5.4",
+        "symfony/stopwatch": "^5.4",
+        "symfony/web-profiler-bundle": "^5.4"
     },
     "config": {
         "optimize-autoloader": true,
@@ -95,7 +100,7 @@
     "extra": {
         "symfony": {
             "allow-contrib": false,
-            "require": "5.3.*"
+            "require": "5.4.*"
         }
     }
 }

Різницю між файлами не показано, бо вона завелика
+ 516 - 162
composer.lock


+ 2 - 0
config/bundles.php

@@ -14,6 +14,8 @@ return [
     Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
     Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
     FOS\ElasticaBundle\FOSElasticaBundle::class => ['all' => true],
+    Knp\Bundle\SnappyBundle\KnpSnappyBundle::class => ['all' => true],
+    Knp\Bundle\GaufretteBundle\KnpGaufretteBundle::class => ['all' => true],
     Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
     Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
 ];

+ 1 - 1
config/packages/doctrine.yaml

@@ -35,4 +35,4 @@ doctrine:
                         prefix: 'App\Entity'
                         alias: App
             adminassos:
-                connection: adminassos
+                connection: adminassos

+ 11 - 0
config/packages/knp_gaufrette.yaml

@@ -0,0 +1,11 @@
+# @see https://github.com/KnpLabs/KnpGaufretteBundle
+knp_gaufrette:
+  adapters:
+    temp:
+      local:
+        directory: '%kernel.project_dir%/var/files/temp'
+        create: true
+  filesystems:
+    temp:
+      adapter: temp
+      alias: temp

+ 9 - 0
config/packages/knp_snappy.yaml

@@ -0,0 +1,9 @@
+knp_snappy:
+    pdf:
+        enabled:    true
+        binary:     '%env(WKHTMLTOPDF_PATH)%'
+        options:    []
+    image:
+        enabled:    true
+        binary:     '%env(WKHTMLTOIMAGE_PATH)%'
+        options:    []

+ 14 - 0
config/packages/messenger.yaml

@@ -0,0 +1,14 @@
+framework:
+    messenger:
+        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
+        failure_transport: failed
+
+        transports:
+            # https://symfony.com/doc/current/messenger.html#transport-configuration
+            async: '%env(MESSENGER_TRANSPORT_DSN)%'
+            failed: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=failed'
+            sync: 'sync://'
+
+        routing:
+            # Route your messages to the transports
+            'App\Message\Command\Export': async

+ 2 - 1
config/packages/twig.yaml

@@ -1,2 +1,3 @@
 twig:
-    default_path: '%kernel.project_dir%/templates'
+    paths:
+        '%kernel.project_dir%/templates': templates

+ 8 - 1
config/routes.yaml

@@ -4,4 +4,11 @@ login_check:
 
 swagger_ui:
   path: /docs
-  controller: api_platform.swagger.action.ui
+  controller: api_platform.swagger.action.ui
+
+ot_internal_secure_file_download:
+  path: /_internal/secure/files/{id}
+  controller: App\Controller\FileController::downloadFile
+  methods: GET
+  requirements:
+    id: '\d+'

+ 14 - 3
config/services.yaml

@@ -9,6 +9,7 @@ services:
         autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
         bind:
             $opentalentConfig: '%kernel.project_dir%%env(OPENTALENT_CONFIG)%'
+            $internalFilesUploadUri: '%env(INTERNAL_FILES_DOWNLOAD_URI)%'
 
     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name
@@ -29,6 +30,8 @@ services:
     App\Service\Organization\Utils:
         public: true
 
+    Gaufrette\Filesystem: '@knp_gaufrette.filesystem_map'
+
     #########################################
     ##  TAG Services ##
     _instanceof:
@@ -36,11 +39,19 @@ services:
             tags: ['app.extensions.access']
         App\Service\Access\OptionalsRolesInterface:
             tags: ['app.optionalsroles']
+        App\Service\Export\ExporterInterface:
+            tags: ['app.exporter']
+        App\Service\Export\Encoder\EncoderInterface:
+            tags: ['app.encoder']
 
-    App\Doctrine\Access\HandleCurrentAccessExtension:
+    App\Service\ServiceIterator\CurrentAccessExtensionIterator:
         - !tagged_iterator app.extensions.access
-    App\Service\Access\HandleOptionalsRoles:
+    App\Service\ServiceIterator\OptionalsRolesIterator:
         - !tagged_iterator app.optionalsroles
+    App\Service\ServiceIterator\ExporterIterator:
+        - !tagged_iterator app.exporter
+    App\Service\ServiceIterator\EncoderIterator:
+        - !tagged_iterator app.encoder
 
     #########################################
     ##  SERIALIZER Decorates ##
@@ -61,4 +72,4 @@ services:
     ##  LISTENER ##
     App\EventListener\DoctrineFilter\DoctrineFilterListener:
         tags:
-            - { name: kernel.event_listener, event: kernel.request }
+            - { name: kernel.event_listener, event: kernel.request }

+ 32 - 0
doc/exports.md

@@ -0,0 +1,32 @@
+# Fonctionnement des exports
+
+## Requête HTTP
+
+Pour déclencher un export, on effectue une requete POST visant une ApiResource de type ExportRequest
+Toutes les ApiResource implémentant l'interface ExportRequestInterface sont traitées par le DataPersister 
+`App\DataPersister\Export\LicenceCmf\ExportRequestDataPersister`.
+
+## ExportRequestDataPersister
+
+Le ExportRequestDataPersister utilise un pattern iterable, c'est-à-dire qu'il balaie (via la classe `ExporterHandler`)
+tous les services implémentant `ExporterInterface` jusqu'à trouver un service qui supporte 
+l'ExportRequest passée en paramètre.
+
+Une fois le bon service d'export trouvé, il appelle la méthode `export($exportRequest)` de ce service.
+
+## Le service d'export
+
+Les services d'export recoivent en paramètre une ExportRequest.
+
+Ils vont ensuite effectuer les opérations suivantes:
+
+1. Construire un modèle : le service génère un objet `ExportModel` (qui peut être selon les cas objet ou une collection d'objets), qui
+contiendra toutes les données nécessaires au rendu du template.
+2. Générer le HTML : le service passe ensuite le modèle au template Twig associé, et récupère le résultat au format HTML. 
+3. Encoder : l'encodeur adapté est trouvé via la classe `EncoderHandler` qui itère sur les encoders disponibles
+jusqu'à trouver le premier qui supporte le format demandé dans l'ExportRequest, puis appelle la méthode `encode($html)`
+de cet encoder, et enfin récupère le contenu final du fichier
+4. Création du fichier : le service `TemporaryFileStorage` est utilisé pour créer le fichier d'export dans 
+un répertoire temporaire. 
+5. Mise à jour de la DB : Un objet `File` est enfin enregistré en base.
+

+ 27 - 35
phpunit.xml.dist

@@ -1,38 +1,30 @@
 <?xml version="1.0" encoding="UTF-8"?>
-
 <!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:noNamespaceSchemaLocation="bin/.phpunit/phpunit.xsd"
-         backupGlobals="false"
-         colors="true"
-         bootstrap="tests/bootstrap.php"
->
-    <php>
-        <ini name="error_reporting" value="-1" />
-        <server name="APP_ENV" value="test" force="true" />
-        <server name="SHELL_VERBOSITY" value="-1" />
-        <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
-        <server name="SYMFONY_PHPUNIT_VERSION" value="8.5" />
-    </php>
-
-    <testsuites>
-        <testsuite name="Project Test Suite">
-            <directory>tests</directory>
-        </testsuite>
-    </testsuites>
-
-    <filter>
-        <whitelist processUncoveredFilesFromWhitelist="true">
-            <directory suffix=".php">src</directory>
-        </whitelist>
-    </filter>
-
-    <listeners>
-        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
-    </listeners>
-
-    <logging>
-        <log type="coverage-html" target="./coverage" lowUpperBound="35" highLowerBound="70"/>
-        <log type="junit" target="./coverage/junit-report.xml"/>
-    </logging>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php">
+  <coverage processUncoveredFiles="true">
+    <include>
+      <directory suffix=".php">src</directory>
+    </include>
+    <report>
+      <html outputDirectory="./coverage" lowUpperBound="35" highLowerBound="70"/>
+    </report>
+  </coverage>
+  <php>
+    <ini name="error_reporting" value="-1"/>
+    <server name="APP_ENV" value="test" force="true"/>
+    <server name="SHELL_VERBOSITY" value="-1"/>
+    <server name="SYMFONY_PHPUNIT_REMOVE" value=""/>
+    <server name="SYMFONY_PHPUNIT_VERSION" value="9.4"/>
+  </php>
+  <testsuites>
+    <testsuite name="Project Test Suite">
+      <directory>tests</directory>
+    </testsuite>
+  </testsuites>
+  <listeners>
+    <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
+  </listeners>
+  <logging>
+    <junit outputFile="./coverage/junit-report.xml"/>
+  </logging>
 </phpunit>

BIN
public/static/ciseaux.png


BIN
public/static/cmf-reseau.png


BIN
public/static/cmf_licence.png


BIN
public/static/footer_report_activity.jpg


BIN
public/static/header_report_activity.jpg


BIN
public/static/logo_welcome.png


BIN
public/static/picto_face.png


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

@@ -0,0 +1,100 @@
+<?php
+declare(strict_types=1);
+
+namespace App\ApiResources\Export;
+
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
+use App\Entity\Access\Access;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Demande d'export d'un fichier
+ * -- C'est la classe de base des ressources de type ExportRequest --
+ */
+abstract class ExportRequest
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform
+     *
+     * @var int
+     */
+    #[ApiProperty(identifier: true)]
+    protected int $id = 0;
+
+    /**
+     * Format de sortie attendu (pdf, txt...)
+     * @var string
+     */
+    #[Assert\Choice(callback: ['\App\Enum\Export\ExportFormatEnum', 'toArray'], message: 'invalid-output-format')]
+    protected string $format;
+
+    /**
+     * The id of the access requesting this export
+     * @var int|null
+     */
+    protected ?int $requesterId = null;
+
+    /**
+     * Should the export be asynchrone
+     * @var bool
+     */
+    protected bool $async = true;
+
+    /**
+     * @return int
+     */
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return string
+     */
+    public function getFormat(): string
+    {
+        return $this->format;
+    }
+
+    /**
+     * @param string $format
+     */
+    public function setFormat(string $format): void
+    {
+        $this->format = $format;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getRequesterId(): ?int
+    {
+        return $this->requesterId;
+    }
+
+    /**
+     * @param int|null $requesterId
+     */
+    public function setRequesterId(?int $requesterId): void
+    {
+        $this->requesterId = $requesterId;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isAsync(): bool
+    {
+        return $this->async;
+    }
+
+    /**
+     * @param bool $async
+     */
+    public function setAsync(bool $async): void
+    {
+        $this->async = $async;
+    }
+}

+ 8 - 0
src/ApiResources/Export/ExportRequestInterface.php

@@ -0,0 +1,8 @@
+<?php
+
+namespace App\ApiResources\Export;
+
+class ExportRequestInterface
+{
+
+}

+ 49 - 0
src/ApiResources/Export/LicenceCmf/LicenceCmfOrganizationER.php

@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+namespace App\ApiResources\Export\LicenceCmf;
+
+use ApiPlatform\Core\Annotation\ApiResource;
+use App\ApiResources\Export\ExportRequest;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Requête d'export d'une licence CMF pour l'organization ciblée
+ *
+ * Appeler avec une requête POST à /export/licence-cmf/organization,
+ * avec un body comme :
+ *
+ *   {"organizationId" : 1}
+ */
+#[ApiResource(
+    collectionOperations: [
+        'post' => [
+            'security' => '(is_granted("ROLE_ADMIN_CORE") or 
+                            is_granted("ROLE_ADMINISTRATIF_MANAGER_CORE") or 
+                           ) and object.getOrganizationId() == user.getOrganization().getId()',
+            'method' => 'POST',
+            'path' => '/licence-cmf/organization',
+        ],
+    ],
+    routePrefix: '/export'
+)]
+class LicenceCmfOrganizationER extends ExportRequest
+{
+    /**
+     * Format de sortie attendu (pdf seulement ici)
+     * @var string
+     */
+    #[Assert\EqualTo('pdf')]
+    protected string $format = 'pdf';
+
+    /**
+     * Retourne l'année de la licence
+     * (toujours l'année courante, sauf dans le cas des tests où on pourra mocker cette méthode)
+     *
+     * @return int
+     */
+    public function getYear(): int
+    {
+        return (int)date('Y');
+    }
+}

+ 65 - 0
src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php

@@ -0,0 +1,65 @@
+<?php
+declare(strict_types=1);
+
+namespace App\DataPersister\Export\LicenceCmf;
+
+use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
+use App\ApiResources\Export\ExportRequest;
+use App\Entity\Access\Access;
+use App\Message\Command\Export;
+use App\Service\ServiceIterator\ExporterIterator;
+use Exception;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Security\Core\Security;
+use Symfony\Component\HttpFoundation\Response;
+
+class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
+{
+    public function __construct(
+        private Security $security,
+        private MessageBusInterface $messageBus,
+        private ExporterIterator $handler
+    ) {}
+
+    public function supports($data, array $context = []): bool
+    {
+        return $data instanceof ExportRequest;
+    }
+
+    /**
+     * @param $exportRequest ExportRequest Une requête d'export
+     * @param array $context
+     * @return Response
+     * @throws Exception
+     */
+    public function persist($exportRequest, array $context = []): Response
+    {
+        /** @var Access $access */
+        $access = $this->security->getUser();
+        $exportRequest->setRequesterId($access->getId());
+
+        if ($exportRequest->isAsync()) {
+
+            // Send the export request to Messenger (@see App\Message\Handler\ExportHandler)
+            $this->messageBus->dispatch(
+                new Export($exportRequest)
+            );
+            return new Response(null, 204);
+
+        } else {
+
+            $exportService = $this->handler->getExporterFor($exportRequest);
+            $file = $exportService->export($exportRequest);
+
+            return new Response('File generated: ' . $file->getId(), 200);
+        }
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function remove($data, array $context = [])
+    {
+        throw new Exception('not supported', 500);
+    }
+}

+ 4 - 3
src/Doctrine/Access/CurrentAccessExtension.php

@@ -7,6 +7,7 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInter
 use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
 use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
 use App\Entity\Access\Access;
+use App\Service\ServiceIterator\CurrentAccessExtensionIterator;
 use Doctrine\ORM\QueryBuilder;
 use Symfony\Component\Security\Core\Security;
 
@@ -18,7 +19,7 @@ final class CurrentAccessExtension implements QueryCollectionExtensionInterface,
 {
     public function __construct(
         private Security $security,
-        private HandleCurrentAccessExtension $handleCurrentAccessExtension
+        private CurrentAccessExtensionIterator $currentAccessExtensionIterator
     )
     { }
 
@@ -45,6 +46,6 @@ final class CurrentAccessExtension implements QueryCollectionExtensionInterface,
             ->setParameter('current_organization', $currentUser->getOrganization())
         ;
 
-        $this->handleCurrentAccessExtension->addWhere($queryBuilder, $operationName);
+        $this->currentAccessExtensionIterator->addWhere($queryBuilder, $operationName);
     }
-}
+}

+ 298 - 2
src/Entity/Core/File.php

@@ -7,6 +7,7 @@ use ApiPlatform\Core\Annotation\ApiResource;
 use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Repository\Core\FileRepository;
+use DateTime;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
@@ -27,20 +28,139 @@ class File
     #[ORM\GeneratedValue]
     private ?int $id = null;
 
+    /**
+     * Propriétaire du fichier
+     *
+     * @var Person
+     */
+    #[ORM\ManyToOne]
+    private Person $person;
+
+    /**
+     * Organisation propriétaire du fichier
+     * @var Organization
+     */
+    #[ORM\ManyToOne]
+    private Organization $organization;
+
+    /**
+     * Slug du fichier (i.e. le chemin d'accès relatif)
+     * @var string
+     */
     #[ORM\Column(length: 255)]
     private string $slug;
 
+    /**
+     * Chemin d'accès du fichier
+     * @var string
+     */
     #[ORM\Column(length: 255)]
     private string $path;
 
+    /**
+     * Nom du fichier
+     * @var string
+     */
     #[ORM\Column(length: 255)]
     private string $name;
 
+    /**
+     * Mimetype du fichier
+     * @var string|null
+     */
     #[ORM\Column(length: 255, nullable: true)]
     private ?string $mimeType = null;
 
-    #[ORM\Column(length: 255)]
-    private string $config;
+    /**
+     * Visibilité du fichier (tout le monde, personne, l'organisation seulement...)
+     * @var string
+     */
+    #[ORM\Column(length: 24, options: ['default' => 'NOBODY'])]
+    private string $visibility = 'NOBODY';
+
+    /**
+     * Configuration particulière associée au fichier (exemple: image recadrée)
+     * @var string|null
+     */
+    #[ORM\Column(type: 'text', length: 255, nullable: true)]
+    private ?string $config;
+
+    /**
+     * Dossier contenant le fichier
+     * @var string
+     */
+    #[ORM\Column(length: 24)]
+    private string $folder;
+
+    /**
+     * Type de document (uploaded, mail, bill...etc)
+     * @var string
+     */
+    #[ORM\Column(length: 50, options: ['default' => 'NONE'])]
+    private string $type = "NONE";
+
+    /**
+     * Taille du document en octets
+     * @var int|null
+     */
+    #[ORM\Column]
+    private ?int $size;
+
+    /**
+     * Un fichier est temporaire par exemple s'il a été généré et est stocké pour être téléchargé dans la foulée
+     * Les fichiers temporaires peuvent être supprimés sans risque, à l'inverse des fichiers uploadés par les
+     * utilisateurs par exemple.
+     *
+     * @var boolean
+     */
+    #[ORM\Column(options: ['default' => false])]
+    private bool $isTemporaryFile = false;
+
+    /**
+     * Date de création du fichier
+     * @var DateTime
+     */
+    #[ORM\Column]
+    private DateTime $createDate;
+
+    /**
+     * Id de l'access ayant créé ce fichier
+     * @var int|null
+     */
+    #[ORM\Column]
+    private ?int $createdBy;
+
+    /**
+     * Date de dernière mise à jour du fichier
+     * @var DateTime
+     */
+    #[ORM\Column]
+    private DateTime $updateDate;
+
+    /**
+     * Id de l'access ayant mis à jour ce fichier le dernier
+     * @var int|null
+     */
+    #[ORM\Column]
+    private ?int $updatedBy;
+
+//    #[ORM\Column]
+//    private ?int $eventReport_id;
+//
+//    #[ORM\Column]
+//    private ?\DateTime $availabilityDate;
+//
+//    #[ORM\Column]
+//    private ?int $documentWish_id;
+//
+//    #[ORM\Column]
+//    private ?int $onlineRegistrationSetting_id;
+//
+//    #[ORM\Column]
+//    private ?int $templateSystem_id;
+//
+//    #[ORM\Column]
+//    private ?int $work_id;
 
     #[ORM\OneToMany(mappedBy: 'image', targetEntity: Person::class, orphanRemoval: true)]
     private Collection $personImages;
@@ -63,6 +183,38 @@ class File
         return $this->id;
     }
 
+    /**
+     * @return Person
+     */
+    public function getPerson(): Person
+    {
+        return $this->person;
+    }
+
+    /**
+     * @param Person $person
+     */
+    public function setPerson(Person $person): void
+    {
+        $this->person = $person;
+    }
+
+    /**
+     * @return Organization
+     */
+    public function getOrganization(): Organization
+    {
+        return $this->organization;
+    }
+
+    /**
+     * @param Organization $organization
+     */
+    public function setOrganization(Organization $organization): void
+    {
+        $this->organization = $organization;
+    }
+
     public function getSlug(): string
     {
         return $this->slug;
@@ -146,6 +298,150 @@ class File
         return $this;
     }
 
+    /**
+     * @return string
+     */
+    public function getVisibility(): string
+    {
+        return $this->visibility;
+    }
+
+    /**
+     * @param string $visibility
+     */
+    public function setVisibility(string $visibility): void
+    {
+        $this->visibility = $visibility;
+    }
+
+    /**
+     * @return string
+     */
+    public function getFolder(): string
+    {
+        return $this->folder;
+    }
+
+    /**
+     * @param string $folder
+     */
+    public function setFolder(string $folder): void
+    {
+        $this->folder = $folder;
+    }
+
+    /**
+     * @return string
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * @param string $type
+     */
+    public function setType(string $type): void
+    {
+        $this->type = $type;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getSize(): ?int
+    {
+        return $this->size;
+    }
+
+    /**
+     * @param int|null $size
+     */
+    public function setSize(?int $size): void
+    {
+        $this->size = $size;
+    }
+
+    /**
+     * @return bool
+     */
+    public function getIsTemporaryFile(): bool
+    {
+        return $this->isTemporaryFile;
+    }
+
+    /**
+     * @param bool $isTemporaryFile
+     */
+    public function setIsTemporaryFile(bool $isTemporaryFile): void
+    {
+        $this->isTemporaryFile = $isTemporaryFile;
+    }
+
+    /**
+     * @return DateTime
+     */
+    public function getCreateDate(): DateTime
+    {
+        return $this->createDate;
+    }
+
+    /**
+     * @param DateTime $createDate
+     */
+    public function setCreateDate(DateTime $createDate): void
+    {
+        $this->createDate = $createDate;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getCreatedBy(): ?int
+    {
+        return $this->createdBy;
+    }
+
+    /**
+     * @param int|null $createdBy
+     */
+    public function setCreatedBy(?int $createdBy): void
+    {
+        $this->createdBy = $createdBy;
+    }
+
+    /**
+     * @return DateTime
+     */
+    public function getUpdateDate(): DateTime
+    {
+        return $this->updateDate;
+    }
+
+    /**
+     * @param DateTime $updateDate
+     */
+    public function setUpdateDate(DateTime $updateDate): void
+    {
+        $this->updateDate = $updateDate;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getUpdatedBy(): ?int
+    {
+        return $this->updatedBy;
+    }
+
+    /**
+     * @param int|null $updatedBy
+     */
+    public function setUpdatedBy(?int $updatedBy): void
+    {
+        $this->updatedBy = $updatedBy;
+    }
+
     public function getOrganizationLogos(): Collection
     {
         return $this->organizationLogos;

+ 16 - 11
src/Entity/Organization/Organization.php

@@ -355,6 +355,22 @@ class Organization
         return $this;
     }
 
+    /**
+     * @return File
+     */
+    public function getLogo(): File
+    {
+        return $this->logo;
+    }
+
+    /**
+     * @param File $logo
+     */
+    public function setLogo(File $logo): void
+    {
+        $this->logo = $logo;
+    }
+
     public function getBillingSetting(): BillingSetting
     {
         return $this->billingSetting;
@@ -763,17 +779,6 @@ class Organization
         return $this;
     }
 
-    public function setLogo(?File $image):self
-    {
-        $this->logo = $image;
-        return $this;
-    }
-
-    public function getLogo(): ?File
-    {
-        return $this->logo;
-    }
-
     public function setImage(?File $image):self
     {
         $this->image = $image;

+ 20 - 0
src/Entity/Organization/Parameters.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace App\Entity\Organization;
 
 use ApiPlatform\Core\Annotation\ApiResource;
+use App\Entity\Core\File;
 use App\Repository\Organization\ParametersRepository;
 use Doctrine\ORM\Mapping as ORM;
 use Symfony\Component\Validator\Constraints as Assert;
@@ -115,6 +116,9 @@ class Parameters
     #[Assert\Choice(callback: ['\App\Enum\Organization\PeriodicityEnum', 'toArray'], message: 'invalid-periodicity')]
     private ?string $educationPeriodicity = null;
 
+    #[ORM\OneToOne(cascade: ['persist', 'remove'])]
+    private File $qrCode;
+
     public function getId(): ?int
     {
         return $this->id;
@@ -479,4 +483,20 @@ class Parameters
 
         return $this;
     }
+
+    /**
+     * @return File
+     */
+    public function getQrCode(): File
+    {
+        return $this->qrCode;
+    }
+
+    /**
+     * @param File $qrCode
+     */
+    public function setQrCode(File $qrCode): void
+    {
+        $this->qrCode = $qrCode;
+    }
 }

+ 39 - 0
src/Enum/Export/ExportFormatEnum.php

@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Enum\Export;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Formats de sortie des fichiers exportés
+ */
+class ExportFormatEnum extends Enum
+{
+    private const PDF ='pdf';
+    private const CSV ='csv';
+    private const TXT = 'txt';
+    private const XLSX = 'xlsx';
+    private const XML = 'xml';
+
+    /** @var array */
+    protected static array $mimeType = [
+        self::PDF => 'application/pdf',
+        self::CSV => 'text/csv',
+        self::TXT => 'text/plain',
+        self::XLSX => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+        self::XML => 'application/xml'
+    ];
+
+    /**
+     * @param  string $formatShortName
+     * @return string
+     */
+    public static function getMimeType($formatShortName)
+    {
+        if (!isset(static::$mimeType[$formatShortName])) {
+            return "Unknown format ($formatShortName)";
+        }
+        return static::$mimeType[$formatShortName];
+    }
+}

+ 33 - 0
src/Message/Command/Export.php

@@ -0,0 +1,33 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Message\Command;
+
+use App\ApiResources\Export\ExportRequest;
+
+/**
+ * Transmission d'une ExportRequest au service d'export associé
+ */
+class Export
+{
+    public function __construct(
+        private ExportRequest $exportRequest
+    )
+    {}
+
+    /**
+     * @return ExportRequest
+     */
+    public function getExportRequest(): ExportRequest
+    {
+        return $this->exportRequest;
+    }
+
+    /**
+     * @param ExportRequest $exportRequest
+     */
+    public function setExportRequest(ExportRequest $exportRequest): void
+    {
+        $this->exportRequest = $exportRequest;
+    }
+}

+ 22 - 0
src/Message/Handler/ExportHandler.php

@@ -0,0 +1,22 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Message\Handler;
+
+use App\Message\Command\Export;
+use App\Service\ServiceIterator\ExporterIterator;
+use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
+
+class ExportHandler implements MessageHandlerInterface
+{
+    public function __construct(
+        private ExporterIterator $handler
+    ) {}
+
+    public function __invoke(Export $export)
+    {
+        $exportRequest = $export->getExportRequest();
+        $exportService = $this->handler->getExporterFor($exportRequest);
+        $exportService->export($exportRequest);
+    }
+}

+ 35 - 3
src/Repository/Access/AccessRepository.php

@@ -5,6 +5,8 @@ namespace App\Repository\Access;
 
 use App\DQL\DateConditions;
 use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use DateTime;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
 use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
@@ -72,11 +74,11 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
     }
 
     /**
-     * @param Access $acces
+     * @param Access $access
      * @return mixed
      * @throws \Exception
      */
-    public function findAllValidAccesses(Access $acces): array
+    public function findAllValidAccesses(Access $access): array
     {
         $datetime = new \DateTime();
         $today = $datetime->format('Y-m-d');
@@ -86,7 +88,7 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
             ->innerJoin('organization.networkOrganizations', 'networkOrganizations')
             ->where('access.person = :person')
             ->andWhere('networkOrganizations.startDate <= :today')
-            ->setParameter('person', $acces->getPerson())
+            ->setParameter('person', $access->getPerson())
             ->setParameter('today', $today)
             ->getQuery()
             ->getResult()
@@ -114,4 +116,34 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
 
         return $result;
     }
+
+    /**
+     * Retourne tous les accesses de l'organization ayant la fonction donnée à la date donnée
+     *
+     * @param Organization $organization
+     * @param $function
+     * @param DateTime|null $date
+     * @return array
+     */
+    public function findByOrganizationAndMission(Organization $organization, $function, \DateTime $date = null): array
+    {
+        if ($date === null)
+            $date = new DateTime();
+
+        $this->_em->getFilters()->disable('date_time_filter');
+
+        $qb = $this->createQueryBuilder('access');
+        $qb
+            ->innerJoin('access.organizationFunction', 'organization_function')
+            ->innerJoin('organization_function.functionType', 'function_type')
+            ->where('function_type.mission = :mission')
+            ->andWhere('access.organization = :id')
+            ->setParameter('id', $organization->getId())
+            ->setParameter('mission', $function)
+        ;
+        DateConditions::addDateInPeriodCondition($qb, 'organization_function', $date->format('Y-m-d'));
+        $this->_em->getFilters()->enable('date_time_filter');
+
+        return $qb->getQuery()->getResult();
+    }
 }

+ 4 - 3
src/Service/Access/Utils.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace App\Service\Access;
 
 use App\Entity\Access\Access;
+use App\Service\ServiceIterator\OptionalsRolesIterator;
 use App\Test\Service\Access\UtilsTest;
 use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
 
@@ -15,7 +16,7 @@ class Utils
 {
     public function __construct(
         private RoleHierarchyInterface $roleHierarchy,
-        private HandleOptionalsRoles $handleOptionalsRoles
+        private OptionalsRolesIterator $optionalsRolesIterator
     )
     {}
 
@@ -51,7 +52,7 @@ class Utils
      * @see UtilsTest::testGetAllRoles()
      */
     public function getAllRoles(Access $access): array {
-        $roles = $this->handleOptionalsRoles->getOptionalsRoles($access);
+        $roles = $this->optionalsRolesIterator->getOptionalsRoles($access);
         return $this->roleHierarchy->getReachableRoleNames(array_merge($access->getRoles(), $roles));
     }
-}
+}

+ 210 - 0
src/Service/Export/BaseExporter.php

@@ -0,0 +1,210 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export;
+
+use App\ApiResources\Export\ExportRequest;
+use App\Entity\Core\File;
+use App\Enum\Export\ExportFormatEnum;
+use App\Repository\Access\AccessRepository;
+use App\Service\Export\Model\ExportModelInterface;
+use App\Service\ServiceIterator\EncoderIterator;
+use App\Service\Storage\TemporaryFileStorage;
+use App\Service\Utils\StringsUtils;
+use Doctrine\ORM\EntityManagerInterface;
+use Exception;
+use Psr\Log\LoggerInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+use Twig\Environment;
+
+/**
+ * Classe de base des services d'export
+ */
+abstract class BaseExporter implements ExporterInterface
+{
+    // dependencies injections
+    protected AccessRepository $accessRepository;
+    protected Environment $twig;
+    protected EncoderIterator $encoderIterator;
+    protected EntityManagerInterface $entityManager;
+    protected TemporaryFileStorage $storage;
+    protected LoggerInterface $logger;
+
+    #[Required]
+    public function setAccessRepository(AccessRepository $accessRepository) { $this->accessRepository = $accessRepository; }
+    #[Required]
+    public function setTwig(Environment $twig) { $this->twig = $twig; }
+    #[Required]
+    public function setEncoderIterator(EncoderIterator $encoderIterator) { $this->encoderIterator = $encoderIterator; }
+    #[Required]
+    public function setEntityManager(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; }
+    #[Required]
+    public function setStorage(TemporaryFileStorage $storage) { $this->storage = $storage; }
+    #[Required]
+    public function setLogger(LoggerInterface $logger) { $this->logger = $logger; }
+
+    public function support(ExportRequest $exportRequest): bool
+    {
+        return false;
+    }
+
+    /**
+     * Exécute l'opération d'export correspondant à la requête passée
+     * en paramètre
+     *
+     * @param ExportRequest $exportRequest
+     * @return File
+     * @throws Exception
+     */
+    public function export(ExportRequest $exportRequest): File
+    {
+        // Génère le modèle à partir de l'exportRequest
+        $model = $this->buildModel($exportRequest);
+
+        // Génère le html à partir du template et du service
+        $html = $this->render($model);
+
+        // Encode le html au format voulu
+        $content = $this->encode($html, $exportRequest->getFormat());
+
+        // Créé le fichier dans le storage adapté
+        $filename = $this->getFileBasename($model);
+        if (!preg_match('/^.+\.' . $exportRequest->getFormat() . '$/i', $filename)) {
+            $filename .= '.' . $exportRequest->getFormat();
+        }
+
+        $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();
+
+        $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);
+        // -->
+
+        $this->entityManager->persist($file);
+        $this->entityManager->flush();
+
+        // Retourne l'objet File ainsi créé
+        return $file;
+    }
+
+    /**
+     * Construit le modèle de données qui servira au render du template
+     *
+     * @param ExportRequest $exportRequest
+     * @return ExportModelInterface
+     * @throws Exception
+     */
+    protected function buildModel(ExportRequest $exportRequest): ExportModelInterface
+    {
+        throw new Exception('not implemented error');
+    }
+
+    /**
+     * Retourne le nom par défaut de cet export,
+     * utilisé pour trouver le template twig ou encore pour nommer
+     * le fichier exporté.
+     *
+     * @return string
+     */
+    protected function getBasename(): string
+    {
+        $arr = explode('\\', static::class);
+        $classname = end($arr);
+        return StringsUtils::camelToSnake(
+            preg_replace(
+                '/^([\w\d]+)Exporter$/',
+                '$1',
+                $classname,
+                1)
+        );
+    }
+
+    /**
+     * Return the path of the twig template for this export
+     * @return string
+     */
+    protected function getTemplatePath(): string {
+        return '@templates/export/' . $this->getBasename() . '.html.twig';
+    }
+
+    /**
+     * Fait le render du template twig à partir du modèle de données
+     *
+     * @param ExportModelInterface $model
+     * @return string Rendu HTML
+     * @throws Exception
+     */
+    protected function render(ExportModelInterface $model): string
+    {
+        try {
+            return $this->twig->render(
+                $this->getTemplatePath(),
+                ['model' => $model]
+            );
+        }
+        catch (\Twig\Error\LoaderError | \Twig\Error\RuntimeError | \Twig\Error\SyntaxError $e) {
+            throw new \Exception('error during template rendering : ' . $e);
+        }
+    }
+
+    /**
+     * Encode le html au format demandé
+     *
+     * @param string $html
+     * @param string $format @see ExportFormatEnum
+     * @return string
+     * @throws Exception
+     */
+    protected function encode(string $html, string $format): string
+    {
+        $encoder = $this->encoderIterator->getEncoderFor($format);
+        return $encoder->encode($html);
+    }
+
+    /**
+     * Retourne le nom du fichier exporté
+     *
+     * @param ExportModelInterface $model
+     * @return string
+     */
+    protected function getFileBasename(ExportModelInterface $model): string
+    {
+        return $this->getBasename();
+    }
+
+    /**
+     * Retourne le type de fichier tel qu'il apparait au niveau du champ File.type
+     *
+     * @return string
+     */
+    protected function getFileType(): string {
+        return 'UNKNOWN';
+    }
+
+    /**
+     * Créé le fichier
+     *
+     * @return mixed
+     * @throws Exception
+     */
+    protected function store(string $name, string $content): string
+    {
+        return $this->storage->write($name, $content);
+    }
+}

+ 11 - 0
src/Service/Export/Encoder/EncoderInterface.php

@@ -0,0 +1,11 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Encoder;
+
+interface EncoderInterface
+{
+    public function support(string $format): bool;
+
+    public function encode(string $html, array $options = []);
+}

+ 59 - 0
src/Service/Export/Encoder/PdfEncoder.php

@@ -0,0 +1,59 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Encoder;
+
+use App\Enum\Export\ExportFormatEnum;
+use \Knp\Snappy\Pdf;
+
+/**
+ * Encode HTML to PDF
+ */
+class PdfEncoder implements EncoderInterface
+{
+    /**
+     * Default encoding options
+     * @see https://wkhtmltopdf.org/libwkhtmltox/
+     *
+     * @var array
+     */
+    private array $defaultOptions = [
+        'margin-top'    => 35,
+        'margin-right'  => 10,
+        'margin-bottom' => 15,
+        'margin-left'   => 15,
+        'header-spacing'   => 5,
+        'enable-local-file-access' => true
+    ];
+
+    public function __construct(
+        private Pdf $knpSnappy
+    ) {}
+
+    public function support(string $format): bool {
+        return $format === ExportFormatEnum::PDF()->getValue();
+    }
+
+    /**
+     * Default encoding options
+     * @return array
+     */
+    public function getDefaultOptions() {
+        return $this->defaultOptions;
+    }
+
+    /**
+     * Encode the given HTML content into PDF, and
+     * return the encoded content
+     *
+     * @param string $html
+     * @param array $options
+     * @return string
+     */
+    public function encode(string $html, array $options = []): string
+    {
+        $options = array_merge($this->defaultOptions, $options);
+
+        return $this->knpSnappy->getOutputFromHtml($html, $options);
+    }
+}

+ 28 - 0
src/Service/Export/ExporterInterface.php

@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export;
+
+use App\ApiResources\Export\ExportRequest;
+
+/**
+ * Classe de base des services d'export
+ */
+interface ExporterInterface
+{
+    /**
+     * Le service supporte-t-il ce type d'ExportRequest
+     *
+     * @param ExportRequest $exportRequest
+     * @return boolean
+     */
+    public function support(ExportRequest $exportRequest): bool;
+
+    /**
+     * Exécute l'opération d'export correspondant à la requête passée
+     * en paramètre
+     *
+     * @param ExportRequest $exportRequest
+     */
+    public function export(ExportRequest $exportRequest);
+}

+ 116 - 0
src/Service/Export/LicenceCmfExporter.php

@@ -0,0 +1,116 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export;
+
+use App\ApiResources\Export\ExportRequest;
+use App\ApiResources\Export\LicenceCmf\LicenceCmfOrganizationER;
+use App\Service\Export\Model\ExportModelInterface;
+use App\Service\Export\Model\LicenceCmf;
+use App\Enum\Access\FunctionEnum;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Export\Model\LicenceCmfCollection;
+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
+{
+    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 function __construct(
+        private OrganizationRepository $organizationRepository,
+        private UploadStorage $uploadStorage,
+    )
+    {}
+
+    public function support($exportRequest): bool
+    {
+        return $exportRequest instanceof LicenceCmfOrganizationER;
+    }
+
+    protected function buildModel(ExportRequest $exportRequest): LicenceCmfCollection
+    {
+        $organization = $this->accessRepository->find($exportRequest->getRequesterId())->getOrganization();
+
+        $licenceCmf = new LicenceCmf();
+        $licenceCmf->setId($organization->getId());
+        $licenceCmf->setYear($exportRequest->getYear());
+        $licenceCmf->setIsOrganizationLicence( $exportRequest instanceof LicenceCmfOrganizationER);
+        $licenceCmf->setOrganizationName($organization->getName());
+        $licenceCmf->setOrganizationIdentifier($organization->getIdentifier());
+
+        $parentFederation = $organization->getNetworkOrganizations()->get(0)->getParent();
+        $licenceCmf->setFederationName($parentFederation->getName());
+
+        $licenceCmf->setColor(
+            $this->getLicenceColor($exportRequest->getYear())
+        );
+
+        $logoId = $organization->getLogo()?->getId();
+        if ($logoId) {
+            $licenceCmf->setLogoUri(
+                $this->uploadStorage->getUri($logoId)
+            );
+        }
+
+        $presidents = $this->accessRepository->findByOrganizationAndMission($organization, FunctionEnum::PRESIDENT()->getValue());
+        if (count($presidents) > 0) {
+            $president = $presidents[0]->getPerson();
+            $licenceCmf->setPersonId($president->getId());
+            $licenceCmf->setPersonGender($president->getGender());
+            $licenceCmf->setPersonFirstName($president->getGivenName());
+            $licenceCmf->setPersonLastName($president->getName());
+        }
+
+        $cmf = $this->organizationRepository->find(self::CMF_ID);
+        $qrCodeId = $cmf->getParameters()?->getQrCode()?->getId();
+        if ($qrCodeId) {
+            $licenceCmf->setQrCodeUri(
+                $this->uploadStorage->getUri($qrCodeId)
+            );
+        }
+
+        $model = new LicenceCmfCollection();
+        $model->setLicences([$licenceCmf]);
+        return $model;
+    }
+
+    /**
+     * @param LicenceCmfCollection $model
+     * @return string
+     */
+    protected function getFileBasename(ExportModelInterface $model): string
+    {
+        return 'licence_cmf_' . $model->getLicences()[0]->getYear() . '.pdf';
+    }
+
+    /**
+     * Retourne le type de fichier tel qu'il apparait au niveau du champ File.type
+     *
+     * @return string
+     */
+    protected function getFileType(): string {
+        return 'LICENCE_CMF';
+    }
+
+    /**
+     * Retourne la couleur de licence pour l'année donnée
+     *
+     * @param int $year
+     * @return string
+     */
+    protected function getLicenceColor(int $year): string {
+        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)];
+    }
+}

+ 6 - 0
src/Service/Export/Model/ExportModelInterface.php

@@ -0,0 +1,6 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Model;
+
+interface ExportModelInterface {}

+ 315 - 0
src/Service/Export/Model/LicenceCmf.php

@@ -0,0 +1,315 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Model;
+
+class LicenceCmf implements ExportModelInterface
+{
+    /**
+     * An id for the licence
+     * @var int
+     */
+    private int $id;
+
+    /**
+     * Year of the licence
+     * @var int
+     */
+    private int $year;
+
+    /**
+     * Is this the licence of an organization and not of a person?
+     * @var bool
+     */
+    private bool $isOrganizationLicence;
+
+    /**
+     * Name of the organization
+     * @var string
+     */
+    private string $organizationName;
+
+    /**
+     * Identifier of the organization
+     * @var string
+     */
+    private string $organizationIdentifier;
+
+    /**
+     * Name of the federation
+     * @var string|null
+     */
+    private string $federationName;
+
+    /**
+     * Color of the licence card for the given year
+     * @var string
+     */
+    private string $color;
+
+    /**
+     * URI of the organization's logo
+     * @var string | null
+     */
+    private ?string $logoUri;
+
+    /**
+     * URI of the CMF QrCode
+     * @var string | null
+     */
+    private ?string $qrCodeUri;
+
+    /**
+     * Gender of the licence owner
+     * @var int|null
+     */
+    private ?int $personId = null;
+
+    /**
+     * Gender of the licence owner
+     * @var string
+     */
+    private string $personGender = '';
+
+    /**
+     * First name of the licence owner
+     * @var string
+     */
+    private string $personFirstName = '';
+
+    /**
+     * Name of the licence owner
+     * @var string
+     */
+    private string $personLastName = '';
+
+    /**
+     * Avatar of the person
+     * @var string|null
+     */
+    private ?string $personAvatarUri = '';
+
+    /**
+     * @return int
+     */
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    /**
+     * @param int $id
+     */
+    public function setId(int $id): void
+    {
+        $this->id = $id;
+    }
+
+    /**
+     * @return int
+     */
+    public function getYear(): int
+    {
+        return $this->year;
+    }
+
+    /**
+     * @param int $year
+     */
+    public function setYear(int $year): void
+    {
+        $this->year = $year;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isOrganizationLicence(): bool
+    {
+        return $this->isOrganizationLicence;
+    }
+
+    /**
+     * @param bool $isOrganizationLicence
+     */
+    public function setIsOrganizationLicence(bool $isOrganizationLicence): void
+    {
+        $this->isOrganizationLicence = $isOrganizationLicence;
+    }
+
+    /**
+     * @return string
+     */
+    public function getOrganizationName(): string
+    {
+        return $this->organizationName;
+    }
+
+    /**
+     * @param string $organizationName
+     */
+    public function setOrganizationName(string $organizationName): void
+    {
+        $this->organizationName = $organizationName;
+    }
+
+    /**
+     * @return string
+     */
+    public function getOrganizationIdentifier(): string
+    {
+        return $this->organizationIdentifier;
+    }
+
+    /**
+     * @param string|null $organizationIdentifier
+     */
+    public function setOrganizationIdentifier(?string $organizationIdentifier): void
+    {
+        $this->organizationIdentifier = $organizationIdentifier;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getFederationName(): ?string
+    {
+        return $this->federationName;
+    }
+
+    /**
+     * @param string $federationName
+     */
+    public function setFederationName(string $federationName): void
+    {
+        $this->federationName = $federationName;
+    }
+
+    /**
+     * @return string
+     */
+    public function getColor(): string
+    {
+        return $this->color;
+    }
+
+    /**
+     * @param string $color
+     */
+    public function setColor(string $color): void
+    {
+        $this->color = $color;
+    }
+
+    /**
+     * @return string
+     */
+    public function getLogoUri(): string
+    {
+        return $this->logoUri;
+    }
+
+    /**
+     * @param string $logoUri
+     */
+    public function setLogoUri(string $logoUri): void
+    {
+        $this->logoUri = $logoUri;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getQrCodeUri(): ?string
+    {
+        return $this->qrCodeUri;
+    }
+
+    /**
+     * @param string|null $qrCodeUri
+     */
+    public function setQrCodeUri(?string $qrCodeUri): void
+    {
+        $this->qrCodeUri = $qrCodeUri;
+    }
+
+    /**
+     * @return int
+     */
+    public function getPersonId(): int
+    {
+        return $this->personId;
+    }
+
+    /**
+     * @param int|null $personId
+     */
+    public function setPersonId(?int $personId): void
+    {
+        $this->personId = $personId;
+    }
+
+    /**
+     * @return string
+     */
+    public function getPersonGender(): string
+    {
+        return $this->personGender;
+    }
+
+    /**
+     * @param string $personGender
+     */
+    public function setPersonGender(string $personGender): void
+    {
+        $this->personGender = $personGender;
+    }
+
+    /**
+     * @return string
+     */
+    public function getPersonFirstName(): string
+    {
+        return $this->personFirstName;
+    }
+
+    /**
+     * @param string $personFirstName
+     */
+    public function setPersonFirstName(string $personFirstName): void
+    {
+        $this->personFirstName = $personFirstName;
+    }
+
+    /**
+     * @return string
+     */
+    public function getPersonLastName(): string
+    {
+        return $this->personLastName;
+    }
+
+    /**
+     * @param string $personLastName
+     */
+    public function setPersonLastName(string $personLastName): void
+    {
+        $this->personLastName = $personLastName;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getPersonAvatarUri(): ?string
+    {
+        return $this->personAvatarUri;
+    }
+
+    /**
+     * @param string|null $personAvatarUri
+     */
+    public function setPersonAvatarUri(?string $personAvatarUri): void
+    {
+        $this->personAvatarUri = $personAvatarUri;
+    }
+}

+ 38 - 0
src/Service/Export/Model/LicenceCmfCollection.php

@@ -0,0 +1,38 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Model;
+
+class LicenceCmfCollection implements ExportModelInterface
+{
+    /**
+     * Les différentes licences contenues dans la collection
+     *
+     * @var array
+     */
+    private array $licences;
+
+    /**
+     * @return array
+     */
+    public function getLicences(): array
+    {
+        return $this->licences;
+    }
+
+    /**
+     * @param array $licences
+     */
+    public function setLicences(array $licences): void
+    {
+        $this->licences = $licences;
+    }
+
+    /**
+     * @param LicenceCmf $licence
+     */
+    public function addLicence(LicenceCmf $licence): void
+    {
+        $this->licences[] = $licence;
+    }
+}

+ 6 - 3
src/Doctrine/Access/HandleCurrentAccessExtension.php → src/Service/ServiceIterator/CurrentAccessExtensionIterator.php

@@ -1,11 +1,13 @@
 <?php
 declare(strict_types=1);
 
-namespace App\Doctrine\Access;
+namespace App\Service\ServiceIterator;
 
+use App\Doctrine\Access\AccessExtensionInterface;
 use Doctrine\ORM\QueryBuilder;
+use Exception;
 
-class HandleCurrentAccessExtension{
+class CurrentAccessExtensionIterator{
     public function __construct(private iterable $extensions)
     { }
 
@@ -15,5 +17,6 @@ class HandleCurrentAccessExtension{
             if($extension->support($operationName))
                 return $extension->addWhere($queryBuilder);
         }
+        throw new Exception('no extension found for this operation');
     }
-}
+}

+ 40 - 0
src/Service/ServiceIterator/EncoderIterator.php

@@ -0,0 +1,40 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\ServiceIterator;
+
+use App\ApiResources\Export\ExportRequest;
+use App\Service\Export\Encoder\EncoderInterface;
+use Exception;
+
+/**
+ * Permet d'itérer sur les services d'encodage
+ */
+class EncoderIterator
+{
+    /**
+     * Pour l'injection des services, voir config/services.yaml, section 'TAG Services'
+     * @param iterable $encoders
+     */
+    public function __construct(
+        private iterable $encoders,
+    ) {}
+
+    /**
+     * Itère sur les services d'encodage disponibles et
+     * retourne le premier qui supporte ce type de requête.
+     *
+     * @param string $format
+     * @return EncoderInterface
+     * @throws Exception
+     */
+    public function getEncoderFor(string $format): EncoderInterface
+    {
+        /** @var EncoderInterface $encoder */
+        foreach ($this->encoders as $encoder){
+            if($encoder->support($format))
+                return $encoder;
+        }
+        throw new Exception('no encoder found for this export request');
+    }
+}

+ 40 - 0
src/Service/ServiceIterator/ExporterIterator.php

@@ -0,0 +1,40 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\ServiceIterator;
+
+use App\ApiResources\Export\ExportRequest;
+use App\Service\Export\ExporterInterface;
+use Exception;
+
+/**
+ * Permet d'itérer sur les services d'export
+ */
+class ExporterIterator
+{
+    /**
+     * Pour l'injection des services, voir config/services.yaml, section 'TAG Services'
+     * @param iterable $exportServices
+     */
+    public function __construct(
+        private iterable $exportServices,
+    ) {}
+
+    /**
+     * Itère sur les services d'export disponibles et
+     * retourne le premier qui supporte ce type de requête.
+     *
+     * @param ExportRequest $exportRequest
+     * @return ExporterInterface
+     * @throws Exception
+     */
+    public function getExporterFor(ExportRequest $exportRequest): ExporterInterface
+    {
+        /** @var ExporterInterface $exportService */
+        foreach ($this->exportServices as $exportService){
+            if($exportService->support($exportRequest))
+                return $exportService;
+        }
+        throw new Exception('no export service found for this export request');
+    }
+}

+ 4 - 3
src/Service/Access/HandleOptionalsRoles.php → src/Service/ServiceIterator/OptionalsRolesIterator.php

@@ -1,11 +1,12 @@
 <?php
 declare(strict_types=1);
 
-namespace App\Service\Access;
+namespace App\Service\ServiceIterator;
 
 use App\Entity\Access\Access;
+use App\Service\Access\OptionalsRolesInterface;
 
-class HandleOptionalsRoles{
+class OptionalsRolesIterator {
 
     public function __construct(private iterable $optionalsRoles)
     { }
@@ -19,4 +20,4 @@ class HandleOptionalsRoles{
         }
         return $roles;
     }
-}
+}

+ 22 - 0
src/Service/Storage/FileStorage.php

@@ -0,0 +1,22 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Storage;
+
+use App\Service\Utils\Path;
+use Knp\Bundle\GaufretteBundle\FilesystemMap;
+
+/**
+ * Base class for file storage
+ */
+abstract class FileStorage
+{
+    public function __construct(
+        protected FilesystemMap $filesystem
+    )
+    {}
+
+    protected function getStorageBaseDir(): string {
+        return Path::join(Path::getProjectDir(), 'var', 'files');
+    }
+}

+ 43 - 0
src/Service/Storage/TemporaryFileStorage.php

@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Storage;
+
+use App\Service\Utils\Path;
+use Exception;
+use Ramsey\Uuid\Uuid;
+
+/**
+ * Gère le stockage des fichiers temporaires, comme les documents générés par les utilisateurs
+ * comme des fichiers d'export
+ */
+class TemporaryFileStorage extends FileStorage
+{
+    protected function getRelativeStorageBaseDir(): string {
+        return 'temp';
+    }
+
+    protected function getStorageBaseDir(): string {
+        // TODO: remplacer par une reference à config/packages/knp_gaufrette.yaml
+        return Path::join(parent::getStorageBaseDir(), $this->getRelativeStorageBaseDir());
+    }
+
+    /**
+     * Write the given content to a temporary file
+     *
+     * @param string $filename
+     * @param string $content
+     * @return string
+     * @throws Exception
+     */
+    public function write(string $filename, string $content): string
+    {
+        // Temp dir name is a concatenation of current time (for convenience and sorting) and a short uuid4
+        $tempDirName = date('Ymd_His') . '_' . substr(Uuid::uuid4()->toString(), 0, 8);
+
+        $filePath = Path::join($tempDirName, $filename);
+        $this->filesystem->get('temp')->getAdapter()->write($filePath, $content);
+
+        return Path::join('temp', $filePath);
+    }
+}

+ 31 - 0
src/Service/Storage/UploadStorage.php

@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Storage;
+
+/**
+ * Gère l'upload et le téléchargement de fichiers par les utilisateurs
+ *
+ * Pour la durée de la migration vers Symfony 5, la gestion des fichiers est déléguée à l'ancienne API
+ *
+ */
+// TODO: revoir le fonctionnement de ce storage pour le mettre sur le même format que les autres
+class UploadStorage
+{
+    public function __construct(private string $internalFilesUploadUri)
+    {}
+
+    private function getBaseDownloadUri(): string {
+        return $this->internalFilesUploadUri;
+    }
+
+    private static function getUploadUri(): string
+    {
+        return '';
+    }
+
+    public function getUri(int $fileId): string {
+        return rtrim(self::getBaseDownloadUri(), '/') . '/' . $fileId;
+    }
+
+}

+ 46 - 0
src/Service/Utils/Path.php

@@ -0,0 +1,46 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Utils;
+
+/**
+ * Various methods to manipulate file paths
+ */
+class Path
+{
+    /**
+     * Returns the application directory
+     *
+     * @return string
+     */
+    public static function getProjectDir(): string
+    {
+        return dirname(__file__, 4);
+    }
+
+    /**
+     * Properly join the $path and $tail as a new valid file's path
+     * @see https://stackoverflow.com/a/15575293/4279120
+     *
+     * Ex:
+     *   Path.join('/var/www/', '/html/') > '/var/www/html'
+     *
+     * Input                   Result
+     * ['','']              >  ''
+     * ['','/']             >  '/'
+     * ['/','a']            >  '/a'
+     * ['/','/a']           >  '/a'
+     * ['abc','def']        >  'abc/def'
+     * ['abc','/def']       >  'abc/def'
+     * ['/abc','def']       >  '/abc/def'
+     * ['','foo.jpg']       >  'foo.jpg'
+     * ['dir','0','a.jpg']  >  'dir/0/a.jpg'
+     *
+     * @return string
+     */
+    public static function join(): string
+    {
+        $paths = array_filter(func_get_args(), function ($s) { return $s !== ''; });
+        return preg_replace('#/+#','/',join('/', $paths));
+    }
+}

+ 19 - 1
src/Service/Utils/StringsUtils.php

@@ -20,4 +20,22 @@ class StringsUtils
     public static function unquote(string $str): string {
         return str_replace("'", "", $str);
     }
-}
+
+    /**
+     * Convert CamelCase formatted string into snake_case
+     * @see https://stackoverflow.com/a/40514305/4279120
+     *
+     * @param string $string
+     * @param string $sep
+     * @return string
+     */
+    public static function camelToSnake(string $string, string $sep = "_"): string {
+        return strtolower(
+            preg_replace(
+            '/(?<=\d)(?=[A-Za-z])|(?<=[A-Za-z])(?=\d)|(?<=[a-z])(?=[A-Z])/',
+            $sep,
+            $string
+            )
+        );
+    }
+}

+ 51 - 0
symfony.lock

@@ -19,6 +19,9 @@
     "blackfire/php-sdk": {
         "version": "v1.23.0"
     },
+    "brick/math": {
+        "version": "0.9.3"
+    },
     "composer/ca-bundle": {
         "version": "1.2.8"
     },
@@ -128,6 +131,24 @@
     "jbouzekri/phumbor-bundle": {
         "version": "2.1.0"
     },
+    "knplabs/gaufrette": {
+        "version": "v0.9.0"
+    },
+    "knplabs/knp-gaufrette-bundle": {
+        "version": "v0.7.1"
+    },
+    "knplabs/knp-snappy": {
+        "version": "v1.4.1"
+    },
+    "knplabs/knp-snappy-bundle": {
+        "version": "1.9",
+        "recipe": {
+            "repo": "github.com/symfony/recipes-contrib",
+            "branch": "master",
+            "version": "1.5",
+            "ref": "c81bdcf4a9d4e7b1959071457f9608631865d381"
+        }
+    },
     "laminas/laminas-code": {
         "version": "4.4.2"
     },
@@ -221,6 +242,12 @@
     "psr/log": {
         "version": "1.1.3"
     },
+    "ramsey/collection": {
+        "version": "1.2.2"
+    },
+    "ramsey/uuid": {
+        "version": "4.2.3"
+    },
     "ruflin/elastica": {
         "version": "3.2"
     },
@@ -230,6 +257,9 @@
     "swaggest/json-schema": {
         "version": "v0.12.38"
     },
+    "symfony/amqp-messenger": {
+        "version": "v5.3.14"
+    },
     "symfony/asset": {
         "version": "v5.1.7"
     },
@@ -275,6 +305,9 @@
     "symfony/doctrine-bridge": {
         "version": "v5.1.7"
     },
+    "symfony/doctrine-messenger": {
+        "version": "v5.3.14"
+    },
     "symfony/dotenv": {
         "version": "v5.1.7"
     },
@@ -352,6 +385,18 @@
             "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
         }
     },
+    "symfony/messenger": {
+        "version": "5.3",
+        "recipe": {
+            "repo": "github.com/symfony/recipes",
+            "branch": "master",
+            "version": "4.3",
+            "ref": "25e3c964d3aee480b3acc3114ffb7940c89edfed"
+        },
+        "files": [
+            "config/packages/messenger.yaml"
+        ]
+    },
     "symfony/monolog-bridge": {
         "version": "v5.3.7"
     },
@@ -415,12 +460,18 @@
     "symfony/polyfill-php81": {
         "version": "v1.23.0"
     },
+    "symfony/process": {
+        "version": "v5.3.14"
+    },
     "symfony/property-access": {
         "version": "v5.1.7"
     },
     "symfony/property-info": {
         "version": "v5.1.7"
     },
+    "symfony/redis-messenger": {
+        "version": "v5.3.14"
+    },
     "symfony/routing": {
         "version": "5.1",
         "recipe": {

+ 348 - 0
templates/export/licence_cmf.html.twig

@@ -0,0 +1,348 @@
+<!DOCTYPE html>
+<html lang="fr">
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+    <style media="all">
+
+        {% block style %}
+        html {
+            width: 21cm;
+            height: 100%;
+        }
+
+        @page {
+            margin: 180px 50px;
+        }
+
+        body {
+            height: 100%;
+        }
+
+        .Style1 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 12px;
+            font-weight: bold;
+            color: #FFFFFF;
+        }
+
+        .Style2 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 12px;
+            font-weight: bold;
+        }
+
+        .Style3 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 8px;
+            color: #FFFFFF;
+            vertical-align: text-top;
+        }
+
+        .Style4 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 9px;
+        }
+
+        .Style5 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 10px;
+        }
+
+        .Style7 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 16px;
+        }
+
+        .Style8 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 12px;
+        }
+
+        .Style9 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 12px;
+            font-weight: bold;
+        }
+
+        .relative {
+            position: relative;
+        }
+
+        #year_head {
+            position: absolute;
+            bottom: 10px;
+            left: 100px;
+            color: #9d1348;
+            font-size: 25px;
+            font-weight: bold;
+        }
+
+        .avatar {
+            max-width: 85px;
+            max-height: 82px;
+        }
+
+        #year_card {
+            position: absolute;
+            bottom: 75px;
+            left: 55px;
+            color: #9d1348;
+            font-size: 14px;
+            font-weight: bold;
+        }
+
+        .Style2 p, .Style4 p {
+            margin: 0;
+            padding: 0;
+        }
+
+        .page_break {
+            page-break-before: always;
+            top: 0 !important;
+            margin-top: 0 !important;
+            position: initial;
+        }
+
+        #card {
+            position: relative;
+            z-index: 1;
+            top: 0;
+            left: 0;
+            margin: 50px auto;
+        }
+
+        .card_dimension {
+            width: 110mm;
+            height: 72mm;
+        }
+
+        #dashed {
+            width: 106mm;
+            height: 66mm;
+            border: 1px dashed #0f0f0f;
+            position: absolute;
+            border-radius: 4px;
+            top: 2mm;
+            left: 2mm;
+            z-index: 2;
+        }
+
+        #scissor{
+            position: absolute;
+            top: 0;
+            left: 15px;
+        }
+
+        .up {
+            padding-top: 100px;
+        }
+
+        .bottom {
+            padding-top: 20px;
+        }
+
+        #qrCode {
+            padding-right: 15px;
+        }
+
+        #avatar{
+            padding-left: 5px;
+        }
+
+        {% endblock style %}
+    </style>
+
+    <link
+            href=
+            "https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
+            rel="stylesheet"
+            integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN"
+            crossorigin="anonymous"
+    />
+
+    <title>Licence CMF</title>
+</head>
+
+<body>
+
+{% block content %}
+    {% for licence in model.licences %}
+        <page data-iri="{{ licence.id }}">
+            <table width="793" border="0" cellspacing="0" cellpadding="0">
+                <tbody>
+                <tr>
+                    <td width="693">
+                        <table width="680" border="0" align="center" cellpadding="0" cellspacing="0">
+                            <tbody>
+                            <tr>
+                                <td width="340" class="relative">
+                                    <img src="{{ asset('static/cmf_licence.png') }}"
+                                            width="170" height="86"/>
+                                    <span id="year_head">{{ licence.year }}</span>
+                                </td>
+                                <td width="340">
+                                    <div align="right">
+                                        <img src="{{ asset('static/cmf-reseau.png') }}"
+                                                width="200" height="86"/>
+                                    </div>
+                                </td>
+                            </tr>
+                            </tbody>
+                        </table>
+                    </td>
+                </tr>
+                <tr>
+                    <td><p class="Style7"></p>
+                        <p class="Style7"></p>
+                        <p class="Style7">
+                            {{ (licence.personGender ~ '_long')| trans }} {{ licence.personLastName }} {{ licence.personFirstName }}
+                            ,</p>
+
+                        <p class="Style7"></p>
+                        <p class="Style8">Vous trouverez ci-joint votre <strong>Licence CMF pour
+                                l’année {{ licence.year }}</strong>.
+                        </p>
+                        <p class="Style8">Vous pouvez : </p>
+                        <ul class="Style8">
+                            <li> Imprimer ce document tel quel et le garder au format A4.</li>
+                            <li> Imprimer ce document sur du papier cartonné et le découper selon les pointillés.</li>
+                            <li> Enregister le PDF pour garder ce document au format numérique.</li>
+                        </ul>
+                        <p class="Style8">Cette licence et le numéro associé vous sera utile pour bénéficier des
+                            avantages partenaires mis en place par la CMF.
+                            Pour connaître les partenariats existants, rendez-vous sur [
+                            <a href="https://www.cmf-musique.org/services/tarifs-preferentiels/">
+                                www.cmf-musique.org/services/tarifs-preferentiels/
+                            </a>]
+                        </p>
+                        <p class="Style8">
+                            Nous vous rappelons que la Licence CMF, personnelle et unique, <br/>
+                            est <strong>valable jusqu’au 31 décembre de l’année {{ licence.year }}</strong>.
+                        </p>
+                        <p class="Style8"></p>
+                        <hr/>
+                    </td>
+                </tr>
+                </tbody>
+            </table>
+
+            <div id="card" class="card_dimension">
+                <table class="card_dimension" align="center" cellpadding="0" cellspacing="0">
+                    <tbody>
+                    <tr>
+                        <td height="26" colspan="3" align="center" valign="bottom" bgcolor="{{ licence.color }}">
+                            <div align="center"><span class="Style1">Licence CMF</span></div>
+                        </td>
+                    </tr>
+                    <tr class="up">
+                        {% if licence.isOrganizationLicence %}
+                            <td width="80" id="avatar">
+                                <div align="center">
+                                    {% if(licence.logoUri is null) %}
+                                        <img src="{{ asset('public/static/picto_face.png') }}"
+                                             width="85"
+                                             height="82"/>
+                                    {% else %}
+                                        <img src="{{ licence.logoUri }}"
+                                             width="85"
+                                             height="82"/>
+                                    {% endif %}
+                                </div>
+                            </td>
+                            <td colspan="2">
+                                <span class="Style2">
+                                    <p>{{ licence.personLastName }} {{ licence.personFirstName }}</p>
+                                    <p>{{ licence.organizationName }}</p>
+                                    <p>{{ licence.federationName }}</p>
+                                    <p>N° : {{ licence.organizationIdentifier }}</p>
+                                    <p>Licence valable jusqu’au 31/12/{{ licence.year }}</p>
+                                </span>
+                            </td>
+                        {% else %}
+                            <td width="80" id="avatar">
+                                <div align="center">
+                                    {% if(licence.personAvatarUri is null) %}
+                                        <img
+                                                src="{{ asset('public/static/picto_face.png') }}"
+                                                width="85"
+                                                height="82"/>
+                                    {% else %}
+                                        <img class="avatar"
+                                             src="{{ asset(licence.personAvatarUri) }}"/>
+                                    {% endif %}
+                                </div>
+                            </td>
+                            <td colspan="2">
+                                <span class="Style2">
+                                   <p>{{ licence.personLastName }} {{ licence.personFirstName }}</p>
+                                   <p>{{ licence.organizationName }}</p>
+                                   <p>?</p>
+                                  <p>N° : {{ licence.organizationIdentifier }}-{{ licence.personId }}</p>
+                                <p>Licence valable jusqu’au 31/12/{{ licence.year }}</p>
+                                </span>
+                            </td>
+                        {% endif %}
+                    </tr>
+
+                    <tr class="bottom">
+                        <td width="70" valign="middle"
+                            style="vertical-align: top;">
+                            <div align="center">
+                                <img src="{{ asset('public/static/cmf_licence.png') }}"
+                                     height="45"/>
+                                <span id="year_card">{{ licence.year }}</span>
+                            </div>
+                        </td>
+                        <td width="140" align="right" valign="middle">
+                            <div align="right"><span class="Style4">
+                                <p>Consultez vos</p>
+                                <p> avantages sur</p>
+                                <p>www.cmf-musique.org</p>
+                                <p> ou flashez ce code</p></span>
+                            </div>
+                        </td>
+                        <td width="70" align="right" valign="middle" id="qrCode">
+                            {% if(licence.qrCodeUri is not null) %}
+                                <img style="margin-right: 10px;"
+                                     src="{{ asset(licence.qrCodeUri) }}"
+                                     alt=""
+                                     width="65" height="65"/>
+                            {% endif %}
+                        </td>
+                    </tr>
+
+                    <tr>
+                        <td colspan="3" align="center" bgcolor="{{ licence.color }}"><span class="Style3">CMF ● cmf@cmf-musique.org ● 01 55 58 22 82 ● www.cmf-musique.org</span>
+                        </td>
+                    </tr>
+
+                    </tbody>
+                </table>
+
+                <div id="dashed"></div>
+                <i class="fa fa-scissors" id="scissor" aria-hidden="true"></i>
+            </div>
+
+
+            <table width="793" border="0" cellspacing="0" cellpadding="0">
+                <tbody>
+                <tr>
+                    <td></td>
+                    <td>
+                        <hr/>
+                        <p align="center" class="Style9">Vous rencontrez des difficultés pour utiliser cette carte ?</p>
+                        <p align="center" class="Style8">Contactez nous : <br/>
+                            CMF<br/>
+                            cmf@cmf-musique.org<br/>
+                            01 55 58 22 82 </p>
+                    </td>
+                    <td></td>
+                </tr>
+                </tbody>
+            </table>
+            <div class="page_break"></div>
+        </page>
+    {% endfor %}
+{% endblock content %}
+</body>
+</html>

+ 2 - 2
tests/Service/Access/AccessProfileCreatorTest.php

@@ -10,7 +10,7 @@ use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Repository\Access\AccessRepository;
 use App\Service\Access\AccessProfileCreator;
-use App\Service\Access\HandleOptionalsRoles;
+use App\Service\Access\OptionalsRolesIterator;
 use App\Service\Access\Utils;
 use App\Service\Organization\OrganizationProfileCreator;
 use PHPUnit\Framework\TestCase;
@@ -139,4 +139,4 @@ class AccessProfileCreatorTest extends TestCase
         $accessProfile = $this->accessProfileCreator->createCompleteAccessProfile($this->access);
         $this->assertFalse($accessProfile->getIsPayor());
     }
-}
+}

+ 5 - 5
tests/Service/Access/UtilsTest.php

@@ -3,8 +3,8 @@
 namespace App\Test\Service\Access;
 
 use App\Entity\Access\Access;
-use App\Service\Access\HandleOptionalsRoles;
 use App\Service\Access\Utils;
+use App\Service\ServiceIterator\OptionalsRolesIterator;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\Security\Core\Role\RoleHierarchy;
 
@@ -19,12 +19,12 @@ class UtilsTest extends TestCase
             ->method('getReachableRoleNames')
             ->willReturn(["ROLE_A", "ROLE_B"]);
 
-        $handleOptionalsRolesMock = $this->getMockBuilder(HandleOptionalsRoles::class)->disableOriginalConstructor()->getMock();
-        $handleOptionalsRolesMock
+        $optionalsRolesIteratorMock = $this->getMockBuilder(OptionalsRolesIterator::class)->disableOriginalConstructor()->getMock();
+        $optionalsRolesIteratorMock
             ->method('getOptionalsRoles')
             ->willReturn(["ROLE_OPT"]);
 
-        $this->utils = new Utils($roleHierarchyMock, $handleOptionalsRolesMock);
+        $this->utils = new Utils($roleHierarchyMock, $optionalsRolesIteratorMock);
     }
 
     /**
@@ -86,4 +86,4 @@ class UtilsTest extends TestCase
 
         $this->assertEquals(['ROLE_A', 'ROLE_B'], $this->utils->getAllRoles($accessMock1));
     }
-}
+}

+ 1 - 1
tests/Service/Dolibarr/DolibarrServiceTest.php

@@ -64,7 +64,7 @@ class DolibarrServiceTest extends TestCase
         $this->client
             ->expects($this->once())
             ->method('request')
-            ->with("GET", "contracts?limit=1&sqlfilters=statut%3D1&thirdparty_ids%3D1")
+            ->with("GET", "contracts?limit=1&sqlfilters=statut%3D1&thirdparty_ids=1")
             ->willReturn($response);
 
         $this->assertEquals(

+ 39 - 0
tests/Service/Export/Encoder/PdfEncoderTest.php

@@ -0,0 +1,39 @@
+<?php
+
+use App\Service\Export\Encoder\PdfEncoder;
+use Knp\Snappy\Pdf;
+use PHPUnit\Framework\TestCase;
+
+class PdfEncoderTest extends TestCase
+{
+    public function testSupport() {
+        $mocker = $this->getMockBuilder(Pdf::class);
+        $knpSnappy = $mocker->getMock();
+        $encoder = new PdfEncoder($knpSnappy);
+
+        $this->assertTrue($encoder->support('pdf'));
+        $this->assertFalse($encoder->support('txt'));
+    }
+
+    public function testGetDefaultOptions() {
+        $mocker = $this->getMockBuilder(Pdf::class);
+        $knpSnappy = $mocker->getMock();
+        $encoder = new PdfEncoder($knpSnappy);
+
+        $this->assertIsArray($encoder->getDefaultOptions());
+    }
+
+    public function testEncode() {
+        $mocker = $this->getMockBuilder(Pdf::class);
+        $knpSnappy = $mocker->getMock();
+        $knpSnappy
+            ->expects(self::once())
+            ->method('getOutputFromHtml')
+            ->with('<div>content</div>')
+            ->willReturn('%%encoded%%');
+
+        $encoder = new PdfEncoder($knpSnappy);
+
+        $this->assertEquals('%%encoded%%', $encoder->encode('<div>content</div>'));
+    }
+}

+ 167 - 0
tests/Service/Export/LicenceCmfExporterTest.php

@@ -0,0 +1,167 @@
+<?php
+
+use App\ApiResources\Export\ExportRequest;
+use App\ApiResources\Export\LicenceCmf\LicenceCmfOrganizationER;
+use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Entity\Network\Network;
+use App\Entity\Network\NetworkOrganization;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Parameters;
+use App\Entity\Person\Person;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Export\Encoder\PdfEncoder;
+use App\Service\Export\LicenceCmfExporter;
+use App\Service\ServiceIterator\EncoderIterator;
+use App\Service\Storage\TemporaryFileStorage;
+use App\Service\Storage\UploadStorage;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\TestCase;
+use Twig\Environment;
+
+class LicenceCmfExporterTest extends TestCase
+{
+    private mixed $exportRequest;
+    private mixed $accessRepo;
+    private mixed $twig;
+    private mixed $encoderIterator;
+    private mixed $em;
+    private mixed $storage;
+    private mixed $organizationRepo;
+    private mixed $uploadStorage;
+    private mixed $access;
+    private mixed $organization;
+    private mixed $cmf;
+    private mixed $networkOrgs;
+    private mixed $collection;
+    private mixed $parent;
+    private mixed $logo;
+    private mixed $presidentAccess;
+    private mixed $president;
+    private mixed $cmfParameters;
+    private mixed $qrCode;
+    private LicenceCmfExporter $exporter;
+    private mixed $encoder;
+
+    public function setUp(): void {
+        $this->exportRequest = $this->getMockBuilder(LicenceCmfOrganizationER::class)->getMock();
+        $this->accessRepo = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $this->twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
+        $this->encoderIterator = $this->getMockBuilder(EncoderIterator::class)->disableOriginalConstructor()->getMock();
+        $this->encoder = $this->getMockBuilder(PdfEncoder::class)->disableOriginalConstructor()->getMock();
+        $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->storage = $this->getMockBuilder(TemporaryFileStorage::class)->disableOriginalConstructor()->getMock();
+        $this->organizationRepo = $this->getMockBuilder(OrganizationRepository::class)->disableOriginalConstructor()->getMock();
+        $this->uploadStorage = $this->getMockBuilder(UploadStorage::class)->disableOriginalConstructor()->getMock();
+        $this->access = $this->getMockBuilder(Access::class)->getMock();
+        $this->organization = $this->getMockBuilder(Organization::class)->getMock();
+        $this->cmf = $this->getMockBuilder(Organization::class)->getMock();
+        $this->networkOrgs = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $this->collection = $this->getMockBuilder(Collection::class)->getMock();
+        $this->parent = $this->getMockBuilder(Organization::class)->getMock();
+        $this->logo = $this->getMockBuilder(File::class)->getMock();
+        $this->presidentAccess = $this->getMockBuilder(Access::class)->getMock();
+        $this->president = $this->getMockBuilder(Person::class)->getMock();
+        $this->cmfParameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $this->qrCode = $this->getMockBuilder(File::class)->getMock();
+
+        $this->exporter = new LicenceCmfExporter($this->organizationRepo, $this->uploadStorage);
+        $this->exporter->setAccessRepository($this->accessRepo);
+        $this->exporter->setTwig($this->twig);
+        $this->exporter->setEncoderIterator($this->encoderIterator);
+        $this->exporter->setEntityManager($this->em);
+        $this->exporter->setStorage($this->storage);
+    }
+
+    public function testSupport() {
+        $unsupportedExportRequest = $this->getMockBuilder(ExportRequest::class)->disableOriginalConstructor()->getMock();
+
+        $this->assertTrue($this->exporter->support($this->exportRequest));
+        $this->assertFalse($this->exporter->support($unsupportedExportRequest));
+    }
+
+    private function prepareModelBuilding() {
+        $this->exportRequest->method('getRequesterId')->willReturn(1);
+        $this->exportRequest->method('getYear')->willReturn(2020);
+        $this->exportRequest->method('getFormat')->willReturn('pdf');
+
+        $this->accessRepo->method('find')->with(1)->willReturn($this->access);
+        $this->access->method('getOrganization')->willReturn($this->organization);
+        $this->organization->method('getId')->willReturn(1);
+        $this->organization->method('getName')->willReturn('my_organization');
+        $this->organization->method('getIdentifier')->willReturn('org1');
+        $this->organization->method('getNetworkOrganizations')->willReturn($this->collection);
+        $this->collection->method('get')->willReturn($this->networkOrgs);
+        $this->networkOrgs->expects(self::once())->method('getParent')->willReturn($this->parent);
+        $this->parent->expects(self::once())->method('getName')->willReturn('my_network');
+        $this->organization->method('getLogo')->willReturn($this->logo);
+        $this->logo->method('getId')->willReturn(1);
+        $this->uploadStorage->method('getUri')->willReturn('http:://foo.bar/1');
+        $this->president->method('getId')->willReturn(1);
+        $this->president->method('getGender')->willReturn('M');
+        $this->president->method('getGivenName')->willReturn('Joe');
+        $this->president->method('getName')->willReturn('Dalton');
+        $this->accessRepo
+            ->expects(self::once())
+            ->method('findByOrganizationAndMission')
+            ->with($this->organization, 'PRESIDENT')
+            ->willReturn([$this->presidentAccess]);
+        $this->presidentAccess->method('getPerson')->willReturn($this->president);
+        $this->cmf->method('getParameters')->willReturn($this->cmfParameters);
+        $this->cmfParameters->expects(self::once())->method('getQrCode')->willReturn($this->qrCode);
+        $this->qrCode->method('getId')->willReturn(1);
+        $this->organizationRepo->expects(self::once())->method('find')->with(12097)->willReturn($this->cmf);
+    }
+
+    public function testBuildModel() {
+        $this->prepareModelBuilding();
+
+        $reflection = new ReflectionClass('App\Service\Export\LicenceCmfExporter');
+        $this->buildModelMethod = $reflection->getMethod('buildModel');
+        $this->buildModelMethod->setAccessible(true);
+
+        $modelCollection = $this->buildModelMethod->invokeArgs($this->exporter, [$this->exportRequest]);
+        $licence = $modelCollection->getLicences()[0];
+
+        $this->assertEquals('my_network', $licence->getFederationName());
+        $this->assertEquals(true, $licence->isOrganizationLicence());
+        $this->assertEquals(2020, $licence->getYear());
+        $this->assertEquals('931572', $licence->getColor());
+        $this->assertEquals('http:://foo.bar/1', $licence->getQrCodeUri());
+        $this->assertEquals('http:://foo.bar/1', $licence->getLogoUri());
+    }
+
+    public function testExport() {
+        $this->prepareModelBuilding();
+
+        $this->twig
+            ->expects(self::once())
+            ->method('render')
+            ->with('@templates/export/licence_cmf.html.twig')
+            ->willReturn('<div>rendered html</div>');
+
+        $this->encoderIterator
+            ->expects(self::once())
+            ->method('getEncoderFor')
+            ->with('pdf')
+            ->willReturn($this->encoder);
+
+        $this->encoder
+            ->expects(self::once())
+            ->method('encode')
+            ->with('<div>rendered html</div>')
+            ->willReturn('%%encoded%%');
+
+        $this->storage
+            ->expects(self::once())
+            ->method('write')
+            ->willReturn('/temp/abcd/licence_cmf_2020.pdf');
+
+        $file = $this->exporter->export($this->exportRequest);
+
+        $this->assertMatchesRegularExpression('/licence_cmf_\d{4}.pdf/', $file->getName());
+    }
+
+}

+ 55 - 0
tests/Service/ServiceIterator/CurrentAccessExtensionIteratorTest.php

@@ -0,0 +1,55 @@
+<?php
+
+use App\Doctrine\Access\AccessExtensionInterface;
+use App\Service\ServiceIterator\CurrentAccessExtensionIterator;
+use Doctrine\ORM\QueryBuilder;
+use PHPUnit\Framework\TestCase;
+
+class CurrentAccessExtensionIteratorTest extends TestCase
+{
+    public function testAddWhere() {
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $mocker = $this->getMockBuilder(AccessExtensionInterface::class);
+
+        $ext1 = $mocker->getMock();
+        $ext1->method('support')->willReturn(false);
+
+        $ext2 = $mocker->getMock();
+        $ext2->method('support')->with('foo')->willReturn(true);
+        $ext2->expects($this->once())->method('addWhere')->with($queryBuilder)->willReturn(true);
+
+        $ext3 = $mocker->getMock();
+        $ext3->method('support')->willReturn(false);
+
+        $extensions = [$ext1, $ext2, $ext3];
+
+        $iterator = new CurrentAccessExtensionIterator($extensions);
+        $actualExt = $iterator->addWhere($queryBuilder, 'foo');
+
+        $this->assertEquals(true, $actualExt);
+    }
+
+    public function testAddWhereError() {
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $mocker = $this->getMockBuilder(AccessExtensionInterface::class);
+
+        $ext1 = $mocker->getMock();
+        $ext1->method('support')->willReturn(false);
+
+        $ext2 = $mocker->getMock();
+        $ext2->method('support')->willReturn(false);
+
+        $extensions = [$ext1, $ext2];
+
+        $iterator = new CurrentAccessExtensionIterator($extensions);
+
+        $this->expectException(Exception::class);
+        $iterator->addWhere($queryBuilder, 'foo');
+    }
+}

+ 45 - 0
tests/Service/ServiceIterator/EncoderIteratorTest.php

@@ -0,0 +1,45 @@
+<?php
+
+use App\Service\Export\Encoder\EncoderInterface;
+use App\Service\ServiceIterator\EncoderIterator;
+use PHPUnit\Framework\TestCase;
+
+class EncoderIteratorTest extends TestCase
+{
+    public function testGetEncoderFor() {
+        $mocker = $this->getMockBuilder(EncoderInterface::class);
+
+        $encoder1 = $mocker->getMock();
+        $encoder1->method('support')->willReturn(false);
+
+        $encoder2 = $mocker->getMock();
+        $encoder2->expects($this->once())->method('support')->with('pdf')->willReturn(true);
+
+        $encoder3 = $mocker->getMock();
+        $encoder3->method('support')->willReturn(false);
+
+        $encoders = [$encoder1, $encoder2, $encoder3];
+
+        $iterator = new EncoderIterator($encoders);
+        $actualEncoder = $iterator->getEncoderFor('pdf');
+
+        $this->assertEquals($encoder2, $actualEncoder);
+    }
+
+    public function testGetEncoderForError() {
+        $mocker = $this->getMockBuilder(EncoderInterface::class);
+
+        $encoder1 = $mocker->getMock();
+        $encoder1->method('support')->willReturn(false);
+
+        $encoder2 = $mocker->getMock();
+        $encoder2->method('support')->willReturn(false);
+
+        $encoders = [$encoder1, $encoder2];
+
+        $iterator = new EncoderIterator($encoders);
+
+        $this->expectException(Exception::class);
+        $iterator->getEncoderFor('gif');
+    }
+}

+ 56 - 0
tests/Service/ServiceIterator/ExporterIteratorTest.php

@@ -0,0 +1,56 @@
+<?php
+
+use App\ApiResources\Export\ExportRequest;
+use App\Service\Export\Encoder\EncoderInterface;
+use App\Service\Export\ExporterInterface;
+use App\Service\ServiceIterator\EncoderIterator;
+use App\Service\ServiceIterator\ExporterIterator;
+use PHPUnit\Framework\TestCase;
+
+class ExporterIteratorTest extends TestCase
+{
+    public function testGetExporterFor() {
+        $exportRequest = $this->getMockBuilder(ExportRequest::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $mocker = $this->getMockBuilder(ExporterInterface::class);
+
+        $exporter1 = $mocker->getMock();
+        $exporter1->method('support')->willReturn(false);
+
+        $exporter2 = $mocker->getMock();
+        $exporter2->expects($this->once())->method('support')->with($exportRequest)->willReturn(true);
+
+        $exporter3 = $mocker->getMock();
+        $exporter3->method('support')->willReturn(false);
+
+        $exporters = [$exporter1, $exporter2, $exporter3];
+
+        $iterator = new ExporterIterator($exporters);
+        $actualExporter = $iterator->getExporterFor($exportRequest);
+
+        $this->assertEquals($exporter2, $actualExporter);
+    }
+
+    public function testGetExporterForError() {
+        $exportRequest = $this->getMockBuilder(ExportRequest::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $mocker = $this->getMockBuilder(ExporterInterface::class);
+
+        $exporter1 = $mocker->getMock();
+        $exporter1->method('support')->willReturn(false);
+
+        $exporter2 = $mocker->getMock();
+        $exporter2->method('support')->willReturn(false);
+
+        $exporters = [$exporter1, $exporter2];
+
+        $iterator = new ExporterIterator($exporters);
+
+        $this->expectException(Exception::class);
+        $iterator->getExporterFor($exportRequest);
+    }
+}

+ 45 - 0
tests/Service/ServiceIterator/OptionalsRolesIteratorTest.php

@@ -0,0 +1,45 @@
+<?php
+
+use App\Entity\Access\Access;
+use App\Service\Access\OptionalsRolesInterface;
+use App\Service\ServiceIterator\OptionalsRolesIterator;
+use PHPUnit\Framework\TestCase;
+
+class OptionalsRolesIteratorTest extends TestCase
+{
+    public function testAddWhere() {
+        $access = $this->getMockBuilder(Access::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $mocker = $this->getMockBuilder(OptionalsRolesInterface::class);
+
+        $role1 = $mocker->getMock();
+        $role1->method('support')->willReturn(false);
+
+        $role2 = $mocker->getMock();
+        $role2->method('support')->with($access)->willReturn(true);
+        $role2->expects($this->once())->method('getRole')->willReturn('ROLE2');
+
+        $role3 = $mocker->getMock();
+        $role3->method('support')->willReturn(false);
+
+        $role4 = $mocker->getMock();
+        $role4->method('support')->with($access)->willReturn(true);
+        $role4->expects($this->once())->method('getRole')->willReturn('ROLE4');
+
+        $roles = [$role1, $role2, $role3, $role4];
+
+        $iterator = new OptionalsRolesIterator($roles);
+        $actualRoles = $iterator->getOptionalsRoles($access);
+
+        $this->assertEquals(['ROLE2', 'ROLE4'], $actualRoles);
+
+        $roles = [$role1, $role3];
+
+        $iterator = new OptionalsRolesIterator($roles);
+        $actualRoles = $iterator->getOptionalsRoles($access);
+
+        $this->assertEquals([], $actualRoles);
+    }
+}

+ 34 - 0
tests/Service/Storage/TemporaryFileStorageTest.php

@@ -0,0 +1,34 @@
+<?php
+
+use App\Service\Storage\TemporaryFileStorage;
+use Gaufrette\Adapter\Local;
+use Gaufrette\Filesystem;
+use Knp\Bundle\GaufretteBundle\FilesystemMap;
+use PHPUnit\Framework\TestCase;
+
+class TemporaryFileStorageTest extends TestCase
+{
+    public function testGetStorageBaseDir() {
+        $fileSystemMap = $this->getMockBuilder(FilesystemMap::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $fileSystem = $this->getMockBuilder(Filesystem::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $adapter = $this->getMockBuilder(Local::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $fileSystemMap->method('get')->willReturn($fileSystem);
+        $fileSystem->method('getAdapter')->willReturn($adapter);
+        $adapter->expects($this->once())->method('write')->willReturnSelf();
+
+        $storage = new TemporaryFileStorage($fileSystemMap);
+        $path = $storage->write('my_file.txt', 'some content');
+
+        $this->assertMatchesRegularExpression(
+            '/temp\/\d{8}_\d{6}_[\w\d]{8}\/my_file.txt/',
+            $path
+        );
+    }
+}

+ 31 - 0
tests/Service/Utils/PathTest.php

@@ -0,0 +1,31 @@
+<?php
+namespace App\Tests\Service\Utils;
+
+use App\Service\Utils\Path;
+use PHPUnit\Framework\TestCase;
+
+class PathTest extends TestCase
+{
+    /**
+     * @see Path::getProjectDir()
+     */
+    public function testGetProjectDir():void
+    {
+        $this->assertFileExists(Path::getProjectDir() . '/phpunit.xml.dist');
+    }
+
+    /**
+     * @see Path::join()
+     */
+    public function testJoin(): void {
+        $this->assertEquals("", Path::join("", ""));
+        $this->assertEquals("/", Path::join("", "/"));
+        $this->assertEquals("/a", Path::join("/", "a"));
+        $this->assertEquals("/a", Path::join("/", "/a"));
+        $this->assertEquals("abc/def", Path::join("abc", "def"));
+        $this->assertEquals("abc/def", Path::join("abc", "/def"));
+        $this->assertEquals("/abc/def", Path::join("/abc", "def"));
+        $this->assertEquals("foo.jpg", Path::join("", "foo.jpg"));
+        $this->assertEquals("dir/0/a.jpg", Path::join("dir", "0", "a.jpg"));
+    }
+}

+ 10 - 1
tests/Service/Utils/StringsUtilsTest.php

@@ -13,4 +13,13 @@ class StringsUtilsTest extends TestCase
     {
         $this->assertEquals("foo", StringsUtils::unquote("'foo"));
     }
-}
+
+    /**
+     * @see StringsUtils::camelToSnake()
+     */
+    public function testCamelToSnake(): void {
+        $this->assertEquals("foo_bar", StringsUtils::camelToSnake("FooBar"));
+        $this->assertEquals("foo-bar", StringsUtils::camelToSnake("FooBar", '-'));
+        $this->assertEquals("foo_bar", StringsUtils::camelToSnake("fooBar"));
+    }
+}

Деякі файли не було показано, через те що забагато файлів було змінено