From fc88a5f40d0001b2e653f53eafce368b7c6e1091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 26 Mar 2024 17:06:49 +0000 Subject: [PATCH] Improve admin UX for configuration of document template (document generation) --- .../unreleased/Feature-20240326-170418.yaml | 5 + .../DocGeneratorTemplateController.php | 132 +++++----- .../Entity/DocGeneratorTemplate.php | 2 +- .../DocGeneratorTemplateRepository.php | 3 +- ...ocGeneratorTemplateRepositoryInterface.php | 23 ++ .../DocGeneratorTemplate/index.html.twig | 63 ++++- .../pick-context.html.twig | 12 +- .../Email/on_generation_failed_email.txt.twig | 4 +- .../Email/send_data_dump_to_admin.txt.twig | 7 + .../Service/Generator/Generator.php | 147 ++++++++---- .../Service/Generator/GeneratorInterface.php | 43 +++- ...erMessageHandledClearStoredObjectCache.php | 64 +++++ .../Service/Messenger/OnGenerationFails.php | 48 ++-- .../Messenger/RequestGenerationHandler.php | 90 ++++++- .../Messenger/RequestGenerationMessage.php | 35 ++- .../Context/Generator/GeneratorTest.php | 27 ++- ...ssageHandledClearStoredObjectCacheTest.php | 107 +++++++++ .../Messenger/OnGenerationFailsTest.php | 226 ++++++++++++++++++ .../translations/messages+intl-icu.fr.yml | 4 + .../translations/messages.fr.yml | 21 +- .../Entity/StoredObject.php | 53 ++++ .../Repository/StoredObjectRepository.php | 5 +- .../StoredObjectRepositoryInterface.php | 22 ++ .../Service/StoredObjectManager.php | 12 + .../Service/StoredObjectManagerInterface.php | 7 + .../{ => Service}/StoredObjectManagerTest.php | 37 ++- .../migrations/Version20240322100107.php | 36 +++ .../AccompanyingPeriodContext.php | 2 +- .../ChillWopiBundle/src/Controller/Editor.php | 14 +- .../unable_to_edit_such_document.html.twig | 34 +++ .../src/Service/Wopi/AuthorizationManager.php | 2 +- .../src/Service/Wopi/UserManager.php | 8 + .../src/translations/messages.fr.yml | 2 + 33 files changed, 1116 insertions(+), 181 deletions(-) create mode 100644 .changes/unreleased/Feature-20240326-170418.yaml create mode 100644 src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepositoryInterface.php create mode 100644 src/Bundle/ChillDocGeneratorBundle/Resources/views/Email/send_data_dump_to_admin.txt.twig create mode 100644 src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnAfterMessageHandledClearStoredObjectCache.php create mode 100644 src/Bundle/ChillDocGeneratorBundle/tests/Service/Messenger/OnAfterMessageHandledClearStoredObjectCacheTest.php create mode 100644 src/Bundle/ChillDocGeneratorBundle/tests/Service/Messenger/OnGenerationFailsTest.php create mode 100644 src/Bundle/ChillDocGeneratorBundle/translations/messages+intl-icu.fr.yml create mode 100644 src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepositoryInterface.php rename src/Bundle/ChillDocStoreBundle/Tests/{ => Service}/StoredObjectManagerTest.php (81%) create mode 100644 src/Bundle/ChillDocStoreBundle/migrations/Version20240322100107.php create mode 100644 src/Bundle/ChillWopiBundle/src/Resources/views/Editor/unable_to_edit_such_document.html.twig create mode 100644 src/Bundle/ChillWopiBundle/src/translations/messages.fr.yml diff --git a/.changes/unreleased/Feature-20240326-170418.yaml b/.changes/unreleased/Feature-20240326-170418.yaml new file mode 100644 index 000000000..f86f55d96 --- /dev/null +++ b/.changes/unreleased/Feature-20240326-170418.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: Improve admin UX to configure document templates for document generation +time: 2024-03-26T17:04:18.351694753+01:00 +custom: + Issue: "268" diff --git a/src/Bundle/ChillDocGeneratorBundle/Controller/DocGeneratorTemplateController.php b/src/Bundle/ChillDocGeneratorBundle/Controller/DocGeneratorTemplateController.php index 788ccb59c..2449daaf4 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Controller/DocGeneratorTemplateController.php +++ b/src/Bundle/ChillDocGeneratorBundle/Controller/DocGeneratorTemplateController.php @@ -16,29 +16,42 @@ use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface; use Chill\DocGeneratorBundle\Context\Exception\ContextNotFoundException; use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository; -use Chill\DocGeneratorBundle\Service\Generator\GeneratorInterface; use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Serializer\Model\Collection; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; -use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; // TODO à mettre dans services use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; final class DocGeneratorTemplateController extends AbstractController { - public function __construct(private readonly ContextManager $contextManager, private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository, private readonly GeneratorInterface $generator, private readonly MessageBusInterface $messageBus, private readonly PaginatorFactory $paginatorFactory, private readonly EntityManagerInterface $entityManager) - { + public function __construct( + private readonly ContextManager $contextManager, + private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository, + private readonly MessageBusInterface $messageBus, + private readonly PaginatorFactory $paginatorFactory, + private readonly EntityManagerInterface $entityManager, + private readonly ClockInterface $clock, + private readonly Security $security, + ) { } /** @@ -163,9 +176,7 @@ final class DocGeneratorTemplateController extends AbstractController throw new NotFoundHttpException(sprintf('Entity with classname %s and id %s is not found', $context->getEntityClass(), $entityId)); } - $contextGenerationData = [ - 'test_file' => null, - ]; + $contextGenerationData = []; if ( $context instanceof DocGeneratorContextWithPublicFormInterface @@ -175,25 +186,39 @@ final class DocGeneratorTemplateController extends AbstractController $builder = $this->createFormBuilder( array_merge( $context->getFormData($template, $entity), - $isTest ? ['test_file' => null, 'show_data' => false] : [] + $isTest ? ['creator' => null, 'dump_only' => false, 'send_result_to' => ''] : [] ) ); $context->buildPublicForm($builder, $template, $entity); } else { $builder = $this->createFormBuilder( - ['test_file' => null, 'show_data' => false] + ['creator' => null, 'show_data' => false, 'send_result_to' => ''] ); } if ($isTest) { - $builder->add('test_file', FileType::class, [ - 'label' => 'Template file', + $builder->add('dump_only', CheckboxType::class, [ + 'label' => 'docgen.Show data instead of generating', 'required' => false, ]); - $builder->add('show_data', CheckboxType::class, [ - 'label' => 'Show data instead of generating', - 'required' => false, + $builder->add('send_result_to', EmailType::class, [ + 'label' => 'docgen.Send report to', + 'help' => 'docgen.Send report errors to this email address', + 'empty_data' => '', + 'required' => true, + 'constraints' => [ + new NotBlank(), + new NotNull(), + ], + ]); + $builder->add('creator', PickUserDynamicType::class, [ + 'label' => 'docgen.Generate as creator', + 'help' => 'docgen.The document will be generated as the given creator', + 'multiple' => false, + 'constraints' => [ + new NotNull(), + ], ]); } @@ -204,8 +229,10 @@ final class DocGeneratorTemplateController extends AbstractController } elseif (!$form->isSubmitted() || ($form->isSubmitted() && !$form->isValid())) { $templatePath = '@ChillDocGenerator/Generator/basic_form.html.twig'; $templateOptions = [ - 'entity' => $entity, 'form' => $form->createView(), - 'template' => $template, 'context' => $context, + 'entity' => $entity, + 'form' => $form->createView(), + 'template' => $template, + 'context' => $context, ]; return $this->render($templatePath, $templateOptions); @@ -218,60 +245,57 @@ final class DocGeneratorTemplateController extends AbstractController $context->contextGenerationDataNormalize($template, $entity, $contextGenerationData) : []; - // if is test, render the data or generate the doc - if ($isTest && isset($form) && $form['show_data']->getData()) { - return $this->render('@ChillDocGenerator/Generator/debug_value.html.twig', [ - 'datas' => json_encode($context->getData($template, $entity, $contextGenerationData), \JSON_PRETTY_PRINT), - ]); - } - if ($isTest) { - $generated = $this->generator->generateDocFromTemplate( - $template, - $entityId, - $contextGenerationDataSanitized, - null, - true, - isset($form) ? $form['test_file']->getData() : null - ); - - return new Response( - $generated, - Response::HTTP_OK, - [ - 'Content-Transfer-Encoding', 'binary', - 'Content-Type' => 'application/vnd.oasis.opendocument.text', - 'Content-Disposition' => 'attachment; filename="generated.odt"', - 'Content-Length' => \strlen($generated), - ], - ); - } - - // this is not a test // we prepare the object to store the document $storedObject = (new StoredObject()) ->setStatus(StoredObject::STATUS_PENDING) ; + if ($isTest) { + // document will be stored during 15 days, if generation is a test + $storedObject->setDeleteAt($this->clock->now()->add(new \DateInterval('P15D'))); + } + $this->entityManager->persist($storedObject); - // we store the generated document - $context - ->storeGenerated( - $template, - $storedObject, - $entity, - $contextGenerationData - ); + // we store the generated document (associate with the original entity, etc.) + // but only if this is not a test + if (!$isTest) { + $context + ->storeGenerated( + $template, + $storedObject, + $entity, + $contextGenerationData + ); + } $this->entityManager->flush(); + if ($isTest) { + $creator = $contextGenerationData['creator']; + $sendResultTo = ($form ?? null)?->get('send_result_to')?->getData() ?? null; + $dumpOnly = ($form ?? null)?->get('dump_only')?->getData() ?? false; + } else { + $creator = $this->security->getUser(); + + if (!$creator instanceof User) { + throw new AccessDeniedHttpException('only authenticated user can request a generation'); + } + + $sendResultTo = null; + $dumpOnly = false; + } + $this->messageBus->dispatch( new RequestGenerationMessage( - $this->getUser(), + $creator, $template, $entityId, $storedObject, $contextGenerationDataSanitized, + $isTest, + $sendResultTo, + $dumpOnly, ) ); diff --git a/src/Bundle/ChillDocGeneratorBundle/Entity/DocGeneratorTemplate.php b/src/Bundle/ChillDocGeneratorBundle/Entity/DocGeneratorTemplate.php index 2438e66d9..e2524e5ba 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Entity/DocGeneratorTemplate.php +++ b/src/Bundle/ChillDocGeneratorBundle/Entity/DocGeneratorTemplate.php @@ -69,7 +69,7 @@ class DocGeneratorTemplate * * @Serializer\Groups({"read"}) */ - private int $id; + private ?int $id = null; /** * @ORM\Column(type="json") diff --git a/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepository.php b/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepository.php index 0f2771b4e..b5f409032 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepository.php +++ b/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepository.php @@ -14,10 +14,9 @@ namespace Chill\DocGeneratorBundle\Repository; use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; -use Doctrine\Persistence\ObjectRepository; use Symfony\Component\HttpFoundation\RequestStack; -final class DocGeneratorTemplateRepository implements ObjectRepository +final class DocGeneratorTemplateRepository implements DocGeneratorTemplateRepositoryInterface { private readonly EntityRepository $repository; diff --git a/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepositoryInterface.php b/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepositoryInterface.php new file mode 100644 index 000000000..e5071e76a --- /dev/null +++ b/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepositoryInterface.php @@ -0,0 +1,23 @@ + + */ +interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository +{ + public function countByEntity(string $entity): int; +} diff --git a/src/Bundle/ChillDocGeneratorBundle/Resources/views/DocGeneratorTemplate/index.html.twig b/src/Bundle/ChillDocGeneratorBundle/Resources/views/DocGeneratorTemplate/index.html.twig index 1adb6872b..cfe071986 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Resources/views/DocGeneratorTemplate/index.html.twig +++ b/src/Bundle/ChillDocGeneratorBundle/Resources/views/DocGeneratorTemplate/index.html.twig @@ -1,5 +1,16 @@ {% extends '@ChillMain/CRUD/Admin/index.html.twig' %} +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_document_action_buttons_group') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_document_action_buttons_group') }} +{% endblock %} + + {% block admin_content %} {% embed '@ChillMain/CRUD/_index.html.twig' %} {% block table_entities_thead_tr %} @@ -11,6 +22,47 @@ {% endblock %} {% block table_entities_tbody %} + {% if entities|length == 0 %} +

{{ 'docgen.Any template configured'|trans }}

+ {% else %} +
+ {% for entity in entities %} +
+
+
+

{{ entity.name|localize_translatable_string }}

+
+
+
+

{{ contextManager.getContextByKey(entity.context).name|trans }}

+
+
+
+
    +
  • +
    + + + + + + +
    +
  • +
  • + {{ entity.file|chill_document_button_group('Template file', true) }} +
  • +
  • + +
  • +
+
+
+ {% endfor %} +
+ {% endif %} + + {% for entity in entities %} {{ entity.id }} @@ -18,7 +70,7 @@ {{ contextManager.getContextByKey(entity.context).name|trans }}
- + @@ -27,7 +79,14 @@
- + {% endfor %} diff --git a/src/Bundle/ChillDocGeneratorBundle/Resources/views/DocGeneratorTemplate/pick-context.html.twig b/src/Bundle/ChillDocGeneratorBundle/Resources/views/DocGeneratorTemplate/pick-context.html.twig index c2f4a9a95..f61725765 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Resources/views/DocGeneratorTemplate/pick-context.html.twig +++ b/src/Bundle/ChillDocGeneratorBundle/Resources/views/DocGeneratorTemplate/pick-context.html.twig @@ -6,18 +6,20 @@

{{ block('title') }}

-
+
{% for key, context in contexts %} -
-
+
+ -
- {{ context.description|trans|nl2br }} +
+
+ {{ context.description|trans|nl2br }} +
{% endfor %} diff --git a/src/Bundle/ChillDocGeneratorBundle/Resources/views/Email/on_generation_failed_email.txt.twig b/src/Bundle/ChillDocGeneratorBundle/Resources/views/Email/on_generation_failed_email.txt.twig index c4ca7079d..594785bfc 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Resources/views/Email/on_generation_failed_email.txt.twig +++ b/src/Bundle/ChillDocGeneratorBundle/Resources/views/Email/on_generation_failed_email.txt.twig @@ -1,6 +1,6 @@ -{{ creator.label }}, +{% if creator is not same as null %}{{ creator.label }},{% endif %} -{{ 'docgen.failure_email.The generation of the document {template_name} failed'|trans({'{template_name}': template.name|localize_translatable_string}) }} +{{ 'docgen.failure_email.The generation of the document %template_name% failed'|trans({'%template_name%': template.name|localize_translatable_string}) }} {{ 'docgen.failure_email.Forward this email to your administrator for solving'|trans }} diff --git a/src/Bundle/ChillDocGeneratorBundle/Resources/views/Email/send_data_dump_to_admin.txt.twig b/src/Bundle/ChillDocGeneratorBundle/Resources/views/Email/send_data_dump_to_admin.txt.twig new file mode 100644 index 000000000..566bfbfa3 --- /dev/null +++ b/src/Bundle/ChillDocGeneratorBundle/Resources/views/Email/send_data_dump_to_admin.txt.twig @@ -0,0 +1,7 @@ +{{ 'docgen.data_dump_email.Dear'|trans }} + +{{ 'docgen.data_dump_email.data_dump_ready_and_link'|trans }} + +{{ link }} + +{{ 'docgen.data_dump_email.link_valid_until'|trans({validity: validity}) }} diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Generator/Generator.php b/src/Bundle/ChillDocGeneratorBundle/Service/Generator/Generator.php index 6c9a6f219..702d7f114 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Service/Generator/Generator.php +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Generator/Generator.php @@ -17,54 +17,88 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; use Chill\DocGeneratorBundle\GeneratorDriver\DriverInterface; use Chill\DocGeneratorBundle\GeneratorDriver\Exception\TemplateException; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\MainBundle\Entity\User; -use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\Yaml\Yaml; class Generator implements GeneratorInterface { private const LOG_PREFIX = '[docgen generator] '; - public function __construct(private readonly ContextManagerInterface $contextManager, private readonly DriverInterface $driver, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, private readonly StoredObjectManagerInterface $storedObjectManager) - { + public function __construct( + private readonly ContextManagerInterface $contextManager, + private readonly DriverInterface $driver, + private readonly ManagerRegistry $objectManagerRegistry, + private readonly LoggerInterface $logger, + private readonly StoredObjectManagerInterface $storedObjectManager + ) { + } + + public function generateDataDump( + DocGeneratorTemplate $template, + int $entityId, + array $contextGenerationDataNormalized, + StoredObject $destinationStoredObject, + User $creator, + bool $clearEntityManagerDuringProcess = true, + ): StoredObject { + return $this->generateFromTemplate( + $template, + $entityId, + $contextGenerationDataNormalized, + $destinationStoredObject, + $creator, + $clearEntityManagerDuringProcess, + true, + ); } - /** - * @template T of File|null - * @template B of bool - * - * @param B $isTest - * @param (B is true ? T : null) $testFile - * - * @psalm-return (B is true ? string : null) - * - * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable - */ public function generateDocFromTemplate( DocGeneratorTemplate $template, int $entityId, array $contextGenerationDataNormalized, - ?StoredObject $destinationStoredObject = null, - bool $isTest = false, - ?File $testFile = null, - ?User $creator = null - ): ?string { - if ($destinationStoredObject instanceof StoredObject && StoredObject::STATUS_PENDING !== $destinationStoredObject->getStatus()) { + StoredObject $destinationStoredObject, + User $creator, + bool $clearEntityManagerDuringProcess = true, + ): StoredObject { + return $this->generateFromTemplate( + $template, + $entityId, + $contextGenerationDataNormalized, + $destinationStoredObject, + $creator, + $clearEntityManagerDuringProcess, + false, + ); + } + + private function generateFromTemplate( + DocGeneratorTemplate $template, + int $entityId, + array $contextGenerationDataNormalized, + StoredObject $destinationStoredObject, + User $creator, + bool $clearEntityManagerDuringProcess = true, + bool $generateDumpOnly = false, + ): StoredObject { + if (StoredObject::STATUS_PENDING !== $destinationStoredObject->getStatus()) { $this->logger->info(self::LOG_PREFIX.'Aborting generation of an already generated document'); throw new ObjectReadyException(); } $this->logger->info(self::LOG_PREFIX.'Starting generation of a document', [ 'entity_id' => $entityId, - 'destination_stored_object' => null === $destinationStoredObject ? null : $destinationStoredObject->getId(), + 'destination_stored_object' => $destinationStoredObject->getId(), ]); $context = $this->contextManager->getContextByDocGeneratorTemplate($template); $entity = $this - ->entityManager + ->objectManagerRegistry + ->getManagerForClass($context->getEntityClass()) ->find($context->getEntityClass(), $entityId) ; @@ -82,17 +116,47 @@ class Generator implements GeneratorInterface $data = $context->getData($template, $entity, $contextGenerationDataNormalized); - $destinationStoredObjectId = $destinationStoredObject instanceof StoredObject ? $destinationStoredObject->getId() : null; - $this->entityManager->clear(); - gc_collect_cycles(); - if (null !== $destinationStoredObjectId) { - $destinationStoredObject = $this->entityManager->find(StoredObject::class, $destinationStoredObjectId); + $destinationStoredObjectId = $destinationStoredObject->getId(); + + if ($clearEntityManagerDuringProcess) { + // we clean the entity manager + $this->objectManagerRegistry->getManagerForClass($context->getEntityClass())?->clear(); + + // this will force php to clean the memory + gc_collect_cycles(); } - if ($isTest && ($testFile instanceof File)) { - $templateDecrypted = file_get_contents($testFile->getPathname()); - } else { + // as we potentially deleted the storedObject from memory, we have to restore it + $destinationStoredObject = $this->objectManagerRegistry + ->getManagerForClass(StoredObject::class) + ->find(StoredObject::class, $destinationStoredObjectId); + + if ($generateDumpOnly) { + $content = Yaml::dump($data, 6); + /* @var StoredObject $destinationStoredObject */ + $destinationStoredObject + ->setType('application/yaml') + ->setFilename(sprintf('%s_yaml', uniqid('doc_', true))) + ->setStatus(StoredObject::STATUS_READY) + ; + + try { + $this->storedObjectManager->write($destinationStoredObject, $content); + } catch (StoredObjectManagerException $e) { + $destinationStoredObject->addGenerationErrors($e->getMessage()); + + throw new GeneratorException([$e->getMessage()], $e); + } + + return $destinationStoredObject; + } + + try { $templateDecrypted = $this->storedObjectManager->read($template->getFile()); + } catch (StoredObjectManagerException $e) { + $destinationStoredObject->addGenerationErrors($e->getMessage()); + + throw new GeneratorException([$e->getMessage()], $e); } try { @@ -105,19 +169,10 @@ class Generator implements GeneratorInterface $template->getFile()->getFilename() ); } catch (TemplateException $e) { + $destinationStoredObject->addGenerationErrors(implode("\n", $e->getErrors())); throw new GeneratorException($e->getErrors(), $e); } - if (true === $isTest) { - $this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [ - 'is_test' => true, - 'entity_id' => $entityId, - 'destination_stored_object' => null === $destinationStoredObject ? null : $destinationStoredObject->getId(), - ]); - - return $generatedResource; - } - /* @var StoredObject $destinationStoredObject */ $destinationStoredObject ->setType($template->getFile()->getType()) @@ -125,15 +180,19 @@ class Generator implements GeneratorInterface ->setStatus(StoredObject::STATUS_READY) ; - $this->storedObjectManager->write($destinationStoredObject, $generatedResource); + try { + $this->storedObjectManager->write($destinationStoredObject, $generatedResource); + } catch (StoredObjectManagerException $e) { + $destinationStoredObject->addGenerationErrors($e->getMessage()); - $this->entityManager->flush(); + throw new GeneratorException([$e->getMessage()], $e); + } $this->logger->info(self::LOG_PREFIX.'Finished generation of a document', [ 'entity_id' => $entityId, 'destination_stored_object' => $destinationStoredObject->getId(), ]); - return null; + return $destinationStoredObject; } } diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Generator/GeneratorInterface.php b/src/Bundle/ChillDocGeneratorBundle/Service/Generator/GeneratorInterface.php index c4ff38ac5..e7db7ba53 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Service/Generator/GeneratorInterface.php +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Generator/GeneratorInterface.php @@ -13,29 +13,48 @@ namespace Chill\DocGeneratorBundle\Service\Generator; use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\MainBundle\Entity\User; -use Symfony\Component\HttpFoundation\File\File; interface GeneratorInterface { /** - * @template T of File|null - * @template B of bool + * Generate a document and store the document on disk. * - * @param B $isTest - * @param (B is true ? T : null) $testFile + * The given $destinationStoredObject will be updated with filename, status, and eventually errors will be stored + * into the object. The number of generation trial will also be incremented. * - * @psalm-return (B is true ? string : null) + * This process requires a huge amount of data. For this reason, the entity manager will be cleaned during the process, + * unless the paarameter `$clearEntityManagerDuringProcess` is set on false. * - * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface|\Throwable + * As the entity manager might be cleaned, the new instance of the stored object will be returned by this method. + * + * Ensure to store change in the database after each generation trial (call `EntityManagerInterface::flush`). + * + * @phpstan-impure + * + * @param StoredObject $destinationStoredObject will be update with filename, status and incremented of generation trials + * + * @throws StoredObjectManagerException if unable to decrypt the template or store the document */ public function generateDocFromTemplate( DocGeneratorTemplate $template, int $entityId, array $contextGenerationDataNormalized, - ?StoredObject $destinationStoredObject = null, - bool $isTest = false, - ?File $testFile = null, - ?User $creator = null - ): ?string; + StoredObject $destinationStoredObject, + User $creator, + bool $clearEntityManagerDuringProcess = true, + ): StoredObject; + + /** + * Generate a data dump, and store it within the `$destinationStoredObject`. + */ + public function generateDataDump( + DocGeneratorTemplate $template, + int $entityId, + array $contextGenerationDataNormalized, + StoredObject $destinationStoredObject, + User $creator, + bool $clearEntityManagerDuringProcess = true, + ): StoredObject; } diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnAfterMessageHandledClearStoredObjectCache.php b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnAfterMessageHandledClearStoredObjectCache.php new file mode 100644 index 000000000..a558f5fde --- /dev/null +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnAfterMessageHandledClearStoredObjectCache.php @@ -0,0 +1,64 @@ + [ + ['afterHandling', 0], + ], + WorkerMessageFailedEvent::class => [ + ['afterFails', 0], + ], + ]; + } + + public function afterHandling(WorkerMessageHandledEvent $event): void + { + if ($event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) { + $this->clearStoredObjectCache(); + } + } + + public function afterFails(WorkerMessageFailedEvent $event): void + { + if ($event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) { + $this->clearStoredObjectCache(); + } + } + + private function clearStoredObjectCache(): void + { + $this->logger->debug('clear the cache after generation of a document'); + + $this->storedObjectManager->clearCache(); + } +} diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php index 57006cb9d..e1bd20ac8 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php @@ -11,10 +11,11 @@ declare(strict_types=1); namespace Chill\DocGeneratorBundle\Service\Messenger; -use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository; +use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface; use Chill\DocGeneratorBundle\Service\Generator\GeneratorException; +use Chill\DocGeneratorBundle\tests\Service\Messenger\OnGenerationFailsTest; use Chill\DocStoreBundle\Entity\StoredObject; -use Chill\DocStoreBundle\Repository\StoredObjectRepository; +use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface; use Chill\MainBundle\Repository\UserRepositoryInterface; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -24,12 +25,22 @@ use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; use Symfony\Contracts\Translation\TranslatorInterface; +/** + * @see OnGenerationFailsTest for test suite + */ final readonly class OnGenerationFails implements EventSubscriberInterface { public const LOG_PREFIX = '[docgen failed] '; - public function __construct(private DocGeneratorTemplateRepository $docGeneratorTemplateRepository, private EntityManagerInterface $entityManager, private LoggerInterface $logger, private MailerInterface $mailer, private StoredObjectRepository $storedObjectRepository, private TranslatorInterface $translator, private UserRepositoryInterface $userRepository) - { + public function __construct( + private DocGeneratorTemplateRepositoryInterface $docGeneratorTemplateRepository, + private EntityManagerInterface $entityManager, + private LoggerInterface $logger, + private MailerInterface $mailer, + private StoredObjectRepositoryInterface $storedObjectRepository, + private TranslatorInterface $translator, + private UserRepositoryInterface $userRepository + ) { } public static function getSubscribedEvents() @@ -45,13 +56,12 @@ final readonly class OnGenerationFails implements EventSubscriberInterface return; } - if (!$event->getEnvelope()->getMessage() instanceof RequestGenerationMessage) { + $message = $event->getEnvelope()->getMessage(); + + if (!$message instanceof RequestGenerationMessage) { return; } - /** @var RequestGenerationMessage $message */ - $message = $event->getEnvelope()->getMessage(); - $this->logger->error(self::LOG_PREFIX.'Docgen failed', [ 'stored_object_id' => $message->getDestinationStoredObjectId(), 'entity_id' => $message->getEntityId(), @@ -79,16 +89,8 @@ final readonly class OnGenerationFails implements EventSubscriberInterface private function warnCreator(RequestGenerationMessage $message, WorkerMessageFailedEvent $event): void { - $creatorId = $message->getCreatorId(); - - if (null === $creator = $this->userRepository->find($creatorId)) { - $this->logger->error(self::LOG_PREFIX.'Creator not found with given id', ['creator_id', $creatorId]); - - return; - } - - if (null === $creator->getEmail() || '' === $creator->getEmail()) { - $this->logger->info(self::LOG_PREFIX.'Creator does not have any email', ['user' => $creator->getUsernameCanonical()]); + if (null === $message->getSendResultToEmail() || '' === $message->getSendResultToEmail()) { + $this->logger->info(self::LOG_PREFIX.'No email associated with this request generation'); return; } @@ -96,7 +98,7 @@ final readonly class OnGenerationFails implements EventSubscriberInterface // if the exception is not a GeneratorException, we try the previous one... $throwable = $event->getThrowable(); if (!$throwable instanceof GeneratorException) { - $throwable = $throwable->getPrevious(); + $throwable = $throwable->getPrevious() ?? $throwable; } if ($throwable instanceof GeneratorException) { @@ -111,8 +113,14 @@ final readonly class OnGenerationFails implements EventSubscriberInterface return; } + if (null === $creator = $this->userRepository->find($message->getCreatorId())) { + $this->logger->error(self::LOG_PREFIX.'Creator not found'); + + return; + } + $email = (new TemplatedEmail()) - ->to($creator->getEmail()) + ->to($message->getSendResultToEmail()) ->subject($this->translator->trans('docgen.failure_email.The generation of a document failed')) ->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig') ->context([ diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationHandler.php b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationHandler.php index 4ec59d9d4..c20971f27 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationHandler.php +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationHandler.php @@ -11,15 +11,21 @@ declare(strict_types=1); namespace Chill\DocGeneratorBundle\Service\Messenger; +use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface; use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository; use Chill\DocGeneratorBundle\Service\Generator\Generator; +use Chill\DocGeneratorBundle\Service\Generator\GeneratorException; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\DocStoreBundle\Repository\StoredObjectRepository; use Chill\MainBundle\Repository\UserRepositoryInterface; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; +use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Handle the request of document generation. @@ -30,8 +36,17 @@ class RequestGenerationHandler implements MessageHandlerInterface private const LOG_PREFIX = '[docgen message handler] '; - public function __construct(private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository, private readonly EntityManagerInterface $entityManager, private readonly Generator $generator, private readonly LoggerInterface $logger, private readonly StoredObjectRepository $storedObjectRepository, private readonly UserRepositoryInterface $userRepository) - { + public function __construct( + private readonly DocGeneratorTemplateRepository $docGeneratorTemplateRepository, + private readonly EntityManagerInterface $entityManager, + private readonly Generator $generator, + private readonly LoggerInterface $logger, + private readonly StoredObjectRepository $storedObjectRepository, + private readonly UserRepositoryInterface $userRepository, + private readonly MailerInterface $mailer, + private readonly TempUrlGeneratorInterface $tempUrlGenerator, + private readonly TranslatorInterface $translator, + ) { } public function __invoke(RequestGenerationMessage $message) @@ -45,25 +60,59 @@ class RequestGenerationHandler implements MessageHandlerInterface } if ($destinationStoredObject->getGenerationTrialsCounter() >= self::AUTHORIZED_TRIALS) { + $this->logger->error(self::LOG_PREFIX.'Request generation abandoned: maximum number of retry reached', [ + 'template_id' => $message->getTemplateId(), + 'destination_stored_object' => $message->getDestinationStoredObjectId(), + 'trial' => $destinationStoredObject->getGenerationTrialsCounter(), + ]); + throw new UnrecoverableMessageHandlingException('maximum number of retry reached'); } $creator = $this->userRepository->find($message->getCreatorId()); + // we increase the number of generation trial in the object, and, in the same time, update the counter + // on the database side. This ensure that, if the script fails for any reason (memory limit reached), the + // counter is inscreased $destinationStoredObject->addGenerationTrial(); $this->entityManager->createQuery('UPDATE '.StoredObject::class.' s SET s.generationTrialsCounter = s.generationTrialsCounter + 1 WHERE s.id = :id') ->setParameter('id', $destinationStoredObject->getId()) ->execute(); - $this->generator->generateDocFromTemplate( - $template, - $message->getEntityId(), - $message->getContextGenerationData(), - $destinationStoredObject, - false, - null, - $creator - ); + try { + if ($message->isDumpOnly()) { + $destinationStoredObject = $this->generator->generateDataDump( + $template, + $message->getEntityId(), + $message->getContextGenerationData(), + $destinationStoredObject, + $creator + ); + + $this->sendDataDump($destinationStoredObject, $message); + } else { + $destinationStoredObject = $this->generator->generateDocFromTemplate( + $template, + $message->getEntityId(), + $message->getContextGenerationData(), + $destinationStoredObject, + $creator + ); + } + } catch (StoredObjectManagerException|GeneratorException $e) { + $this->entityManager->flush(); + + $this->logger->error(self::LOG_PREFIX.'Request generation failed', [ + 'template_id' => $message->getTemplateId(), + 'destination_stored_object' => $message->getDestinationStoredObjectId(), + 'trial' => $destinationStoredObject->getGenerationTrialsCounter(), + 'error' => $e->getTraceAsString(), + ]); + + throw $e; + } + + $this->entityManager->flush(); $this->logger->info(self::LOG_PREFIX.'Request generation finished', [ 'template_id' => $message->getTemplateId(), @@ -71,4 +120,23 @@ class RequestGenerationHandler implements MessageHandlerInterface 'duration_int' => (new \DateTimeImmutable('now'))->getTimestamp() - $message->getCreatedAt()->getTimestamp(), ]); } + + private function sendDataDump(StoredObject $destinationStoredObject, RequestGenerationMessage $message): void + { + $url = $this->tempUrlGenerator->generate('GET', $destinationStoredObject->getFilename(), 3600); + $parts = []; + parse_str(parse_url((string) $url->url)['query'], $parts); + $validity = \DateTimeImmutable::createFromFormat('U', $parts['temp_url_expires']); + + $email = (new TemplatedEmail()) + ->to($message->getSendResultToEmail()) + ->textTemplate('@ChillDocGenerator/Email/send_data_dump_to_admin.txt.twig') + ->context([ + 'link' => $url->url, + 'validity' => $validity, + ]) + ->subject($this->translator->trans('docgen.data_dump_email.subject')); + + $this->mailer->send($email); + } } diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationMessage.php b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationMessage.php index 092073817..d41485346 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationMessage.php +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationMessage.php @@ -15,27 +15,33 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\MainBundle\Entity\User; -class RequestGenerationMessage +final readonly class RequestGenerationMessage { - private readonly int $creatorId; + private int $creatorId; - private readonly int $templateId; + private int $templateId; - private readonly int $destinationStoredObjectId; + private int $destinationStoredObjectId; - private readonly \DateTimeImmutable $createdAt; + private \DateTimeImmutable $createdAt; + + private ?string $sendResultToEmail; public function __construct( User $creator, DocGeneratorTemplate $template, - private readonly int $entityId, + private int $entityId, StoredObject $destinationStoredObject, - private readonly array $contextGenerationData + private array $contextGenerationData, + private bool $isTest = false, + ?string $sendResultToEmail = null, + private bool $dumpOnly = false, ) { $this->creatorId = $creator->getId(); $this->templateId = $template->getId(); $this->destinationStoredObjectId = $destinationStoredObject->getId(); $this->createdAt = new \DateTimeImmutable('now'); + $this->sendResultToEmail = $sendResultToEmail ?? $creator->getEmail(); } public function getCreatorId(): int @@ -67,4 +73,19 @@ class RequestGenerationMessage { return $this->createdAt; } + + public function isTest(): bool + { + return $this->isTest; + } + + public function getSendResultToEmail(): ?string + { + return $this->sendResultToEmail; + } + + public function isDumpOnly(): bool + { + return $this->dumpOnly; + } } diff --git a/src/Bundle/ChillDocGeneratorBundle/tests/Service/Context/Generator/GeneratorTest.php b/src/Bundle/ChillDocGeneratorBundle/tests/Service/Context/Generator/GeneratorTest.php index 2272f343e..0bb274228 100644 --- a/src/Bundle/ChillDocGeneratorBundle/tests/Service/Context/Generator/GeneratorTest.php +++ b/src/Bundle/ChillDocGeneratorBundle/tests/Service/Context/Generator/GeneratorTest.php @@ -20,7 +20,9 @@ use Chill\DocGeneratorBundle\Service\Generator\ObjectReadyException; use Chill\DocGeneratorBundle\Service\Generator\RelatedEntityNotFoundException; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; +use Chill\MainBundle\Entity\User; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -66,7 +68,11 @@ class GeneratorTest extends TestCase $entityManager->find('DummyClass', Argument::type('int')) ->willReturn($entity); $entityManager->clear()->shouldBeCalled(); - $entityManager->flush()->shouldBeCalled(); + $entityManager->flush()->shouldNotBeCalled(); + + $managerRegistry = $this->prophesize(ManagerRegistry::class); + $managerRegistry->getManagerForClass('DummyClass')->willReturn($entityManager->reveal()); + $managerRegistry->getManagerForClass(StoredObject::class)->willReturn($entityManager->reveal()); $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); $storedObjectManager->read($templateStoredObject)->willReturn('template'); @@ -75,7 +81,7 @@ class GeneratorTest extends TestCase $generator = new Generator( $contextManagerInterface->reveal(), $driver->reveal(), - $entityManager->reveal(), + $managerRegistry->reveal(), new NullLogger(), $storedObjectManager->reveal() ); @@ -84,7 +90,8 @@ class GeneratorTest extends TestCase $template, 1, [], - $destinationStoredObject + $destinationStoredObject, + new User() ); } @@ -95,7 +102,7 @@ class GeneratorTest extends TestCase $generator = new Generator( $this->prophesize(ContextManagerInterface::class)->reveal(), $this->prophesize(DriverInterface::class)->reveal(), - $this->prophesize(EntityManagerInterface::class)->reveal(), + $this->prophesize(ManagerRegistry::class)->reveal(), new NullLogger(), $this->prophesize(StoredObjectManagerInterface::class)->reveal() ); @@ -108,7 +115,8 @@ class GeneratorTest extends TestCase $template, 1, [], - $destinationStoredObject + $destinationStoredObject, + new User() ); } @@ -136,10 +144,14 @@ class GeneratorTest extends TestCase $entityManager->find(Argument::type('string'), Argument::type('int')) ->willReturn(null); + $managerRegistry = $this->prophesize(ManagerRegistry::class); + $managerRegistry->getManagerForClass('DummyClass')->willReturn($entityManager->reveal()); + $managerRegistry->getManagerForClass(StoredObject::class)->willReturn($entityManager->reveal()); + $generator = new Generator( $contextManagerInterface->reveal(), $this->prophesize(DriverInterface::class)->reveal(), - $entityManager->reveal(), + $managerRegistry->reveal(), new NullLogger(), $this->prophesize(StoredObjectManagerInterface::class)->reveal() ); @@ -148,7 +160,8 @@ class GeneratorTest extends TestCase $template, 1, [], - $destinationStoredObject + $destinationStoredObject, + new User() ); } } diff --git a/src/Bundle/ChillDocGeneratorBundle/tests/Service/Messenger/OnAfterMessageHandledClearStoredObjectCacheTest.php b/src/Bundle/ChillDocGeneratorBundle/tests/Service/Messenger/OnAfterMessageHandledClearStoredObjectCacheTest.php new file mode 100644 index 000000000..9996b9b86 --- /dev/null +++ b/src/Bundle/ChillDocGeneratorBundle/tests/Service/Messenger/OnAfterMessageHandledClearStoredObjectCacheTest.php @@ -0,0 +1,107 @@ +prophesize(StoredObjectManagerInterface::class); + $storedObjectManager->clearCache()->shouldNotBeCalled(); + + $eventSubscriber = $this->buildEventSubscriber($storedObjectManager->reveal()); + + $eventSubscriber->afterHandling($this->buildEventSuccess(new \stdClass())); + $eventSubscriber->afterFails($this->buildEventFailed(new \stdClass())); + } + + public function testThatConcernedEventCallAClearCache(): void + { + $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); + $storedObjectManager->clearCache()->shouldBeCalledTimes(2); + + $eventSubscriber = $this->buildEventSubscriber($storedObjectManager->reveal()); + + $eventSubscriber->afterHandling($this->buildEventSuccess($this->buildRequestGenerationMessage())); + $eventSubscriber->afterFails($this->buildEventFailed($this->buildRequestGenerationMessage())); + } + + private function buildRequestGenerationMessage( + ): RequestGenerationMessage { + $creator = new User(); + $creator->setEmail('fake@example.com'); + + $class = new \ReflectionClass($creator); + $property = $class->getProperty('id'); + $property->setAccessible(true); + $property->setValue($creator, 1); + + $template ??= new DocGeneratorTemplate(); + $class = new \ReflectionClass($template); + $property = $class->getProperty('id'); + $property->setAccessible(true); + $property->setValue($template, 2); + + $destinationStoredObject = new StoredObject(); + $class = new \ReflectionClass($destinationStoredObject); + $property = $class->getProperty('id'); + $property->setAccessible(true); + $property->setValue($destinationStoredObject, 3); + + return new RequestGenerationMessage( + $creator, + $template, + 1, + $destinationStoredObject, + [], + ); + } + + private function buildEventSubscriber(StoredObjectManagerInterface $storedObjectManager): OnAfterMessageHandledClearStoredObjectCache + { + return new OnAfterMessageHandledClearStoredObjectCache($storedObjectManager, new NullLogger()); + } + + private function buildEventFailed(object $message): WorkerMessageFailedEvent + { + $envelope = new Envelope($message); + + return new WorkerMessageFailedEvent($envelope, 'testing', new \RuntimeException()); + } + + private function buildEventSuccess(object $message): WorkerMessageHandledEvent + { + $envelope = new Envelope($message); + + return new WorkerMessageHandledEvent($envelope, 'test_receiver'); + } +} diff --git a/src/Bundle/ChillDocGeneratorBundle/tests/Service/Messenger/OnGenerationFailsTest.php b/src/Bundle/ChillDocGeneratorBundle/tests/Service/Messenger/OnGenerationFailsTest.php new file mode 100644 index 000000000..f96b03020 --- /dev/null +++ b/src/Bundle/ChillDocGeneratorBundle/tests/Service/Messenger/OnGenerationFailsTest.php @@ -0,0 +1,226 @@ +prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldNotBeCalled(); + + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send()->shouldNotBeCalled(); + + $eventSubscriber = $this->buildOnGenerationFailsEventSubscriber( + entityManager: $entityManager->reveal(), + mailer: $mailer->reveal() + ); + + $event = $this->buildEvent(new \stdClass()); + + $eventSubscriber->onMessageFailed($event); + } + + public function testMessageThatWillBeRetriedAreNotHandled(): void + { + $storedObject = new StoredObject(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldNotBeCalled(); + + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send()->shouldNotBeCalled(); + + $eventSubscriber = $this->buildOnGenerationFailsEventSubscriber( + entityManager: $entityManager->reveal(), + mailer: $mailer->reveal() + ); + + $event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject)); + $event->setForRetry(); + + $eventSubscriber->onMessageFailed($event); + } + + public function testThatANotRetriyableEventWillMarkObjectAsFailed(): void + { + $storedObject = new StoredObject(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send(Argument::type(RawMessage::class), Argument::any())->shouldBeCalled(); + + $eventSubscriber = $this->buildOnGenerationFailsEventSubscriber( + entityManager: $entityManager->reveal(), + mailer: $mailer->reveal(), + storedObject: $storedObject + ); + + $event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject)); + + $eventSubscriber->onMessageFailed($event); + + self::assertEquals(StoredObject::STATUS_FAILURE, $storedObject->getStatus()); + } + + public function testThatANonRetryableEventSendAnEmail(): void + { + $storedObject = new StoredObject(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $mailer = $this->prophesize(MailerInterface::class); + $mailer->send( + Argument::that(function ($arg): bool { + if (!$arg instanceof Email) { + return false; + } + + foreach ($arg->getTo() as $to) { + if ('test@test.com' === $to->getAddress()) { + return true; + } + } + + return false; + }), + Argument::any() + ) + ->shouldBeCalled(); + + $eventSubscriber = $this->buildOnGenerationFailsEventSubscriber( + entityManager: $entityManager->reveal(), + mailer: $mailer->reveal(), + storedObject: $storedObject + ); + + $event = $this->buildEvent($this->buildRequestGenerationMessage($storedObject, sendResultToEmail: 'test@test.com')); + + $eventSubscriber->onMessageFailed($event); + } + + private function buildRequestGenerationMessage( + StoredObject $destinationStoredObject, + ?User $creator = null, + ?DocGeneratorTemplate $template = null, + array $contextGenerationData = [], + bool $isTest = false, + ?string $sendResultToEmail = null, + ): RequestGenerationMessage { + if (null === $creator) { + $creator = new User(); + $creator->setEmail('fake@example.com'); + } + + if (null === $creator->getId()) { + $class = new \ReflectionClass($creator); + $property = $class->getProperty('id'); + $property->setAccessible(true); + $property->setValue($creator, 1); + } + + $template ??= new DocGeneratorTemplate(); + $class = new \ReflectionClass($template); + $property = $class->getProperty('id'); + $property->setAccessible(true); + $property->setValue($template, 2); + + $class = new \ReflectionClass($destinationStoredObject); + $property = $class->getProperty('id'); + $property->setAccessible(true); + $property->setValue($destinationStoredObject, 3); + + return new RequestGenerationMessage( + $creator, + $template, + 1, + $destinationStoredObject, + $contextGenerationData, + $isTest, + $sendResultToEmail + ); + } + + private function buildOnGenerationFailsEventSubscriber( + ?StoredObject $storedObject = null, + ?EntityManagerInterface $entityManager = null, + ?MailerInterface $mailer = null, + ): OnGenerationFails { + $storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class); + $storedObjectRepository->find(Argument::type('int'))->willReturn($storedObject ?? new StoredObject()); + + if (null === $entityManager) { + $entityManagerProphecy = $this->prophesize(EntityManagerInterface::class); + } + + if (null === $mailer) { + $mailerProphecy = $this->prophesize(MailerInterface::class); + } + + $translator = $this->prophesize(TranslatorInterface::class); + $translator->trans(Argument::type('string'))->will(fn ($args) => $args[0]); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->find(Argument::type('int'))->willReturn(new User()); + + $docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class); + $docGeneratorTemplateRepository->find(Argument::type('int'))->willReturn(new DocGeneratorTemplate()); + + return new OnGenerationFails( + $docGeneratorTemplateRepository->reveal(), + $entityManager ?? $entityManagerProphecy->reveal(), + new NullLogger(), + $mailer ?? $mailerProphecy->reveal(), + $storedObjectRepository->reveal(), + $translator->reveal(), + $userRepository->reveal() + ); + } + + private function buildEvent(object $message): WorkerMessageFailedEvent + { + $envelope = new Envelope($message); + + return new WorkerMessageFailedEvent($envelope, 'testing', new \RuntimeException()); + } +} diff --git a/src/Bundle/ChillDocGeneratorBundle/translations/messages+intl-icu.fr.yml b/src/Bundle/ChillDocGeneratorBundle/translations/messages+intl-icu.fr.yml new file mode 100644 index 000000000..df20907c9 --- /dev/null +++ b/src/Bundle/ChillDocGeneratorBundle/translations/messages+intl-icu.fr.yml @@ -0,0 +1,4 @@ +docgen: + data_dump_email: + link_valid_until: >- + Ce lien est valide jusqu'au {validity, date, full}, {validity, time, medium} diff --git a/src/Bundle/ChillDocGeneratorBundle/translations/messages.fr.yml b/src/Bundle/ChillDocGeneratorBundle/translations/messages.fr.yml index 5e55d6df8..1fdf14c02 100644 --- a/src/Bundle/ChillDocGeneratorBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillDocGeneratorBundle/translations/messages.fr.yml @@ -14,13 +14,31 @@ docgen: Doc generation is pending: La génération de ce document est en cours Come back later: Revenir plus tard + Send report to: Envoyer le rapport à + Send report errors to this email address: Les rapports d'erreurs seront envoyés à l'adresse email indiquée + Generate as creator: Générer en tant que + The document will be generated as the given creator: Le document sera généré à la place de l'utilisateur indiqué + Show data instead of generating: Montrer les données au lieu de générer le document + + Any template configured: Aucun gabarit de document configuré + + entity_id_placeholder: Identifiant de l'entité + failure_email: The generation of a document failed: La génération d'un document a échoué - The generation of the document {template_name} failed: La génération d'un document à partir du modèle {{ template_name }} a échoué. + The generation of the document %template_name% failed: La génération d'un document à partir du modèle {{ template_name }} a échoué. The following errors were encoutered: Les erreurs suivantes ont été rencontrées Forward this email to your administrator for solving: Faites suivre ce message vers votre administrateur pour la résolution du problème. References: Références + data_dump_email: + subject: Contenu des données de génération de document disponible + Dear: Cher + data_dump_ready_and_link: >- + Le contenu des données est disponible. Vous pouvez le télécharger à l'aide du lien suivant: + + + crud: docgen_template: index: @@ -28,5 +46,4 @@ crud: add_new: Créer -Show data instead of generating: Montrer les données au lieu de générer le document Template file: Fichier modèle diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php index 2ee60d80a..4a16d33f4 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php @@ -25,6 +25,11 @@ use Symfony\Component\Serializer\Annotation as Serializer; /** * Represent a document stored in an object store. * + * StoredObjects 's content should be read and written using the @see{StoredObjectManagerInterface}. + * + * The property `$deleteAt` allow a deletion of the document after the given date. But this property should + * be set before the document is actually written by the StoredObjectManager. + * * @ORM\Entity * * @ORM\Table("chill_doc.stored_object") @@ -117,6 +122,16 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa */ private int $generationTrialsCounter = 0; + /** + * @ORM\Column(type="datetime_immutable", nullable=true, options={"default": null}) + */ + private ?\DateTimeImmutable $deleteAt = null; + + /** + * @ORM\Column(type="text", nullable=false, options={"default": ""}) + */ + private string $generationErrors = ''; + /** * @param StoredObject::STATUS_* $status */ @@ -144,6 +159,11 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa */ public function getCreationDate(): \DateTime { + if (null === $this->createdAt) { + // this scenario will quite never happens + return new \DateTime('now'); + } + return \DateTime::createFromImmutable($this->createdAt); } @@ -303,4 +323,37 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa { return self::STATUS_FAILURE === $this->getStatus(); } + + public function getDeleteAt(): ?\DateTimeImmutable + { + return $this->deleteAt; + } + + public function setDeleteAt(?\DateTimeImmutable $deleteAt): StoredObject + { + $this->deleteAt = $deleteAt; + + return $this; + } + + public function getGenerationErrors(): string + { + return $this->generationErrors; + } + + /** + * Adds generation errors to the stored object. + * + * The existing generation errors are not removed + * + * @param string $generationErrors the generation errors to be added + * + * @return StoredObject the modified StoredObject instance + */ + public function addGenerationErrors(string $generationErrors): StoredObject + { + $this->generationErrors = $this->generationErrors.$generationErrors."\n"; + + return $this; + } } diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepository.php index d2e715f7e..84bc7d4cb 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepository.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepository.php @@ -14,11 +14,10 @@ namespace Chill\DocStoreBundle\Repository; use Chill\DocStoreBundle\Entity\StoredObject; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; -use Doctrine\Persistence\ObjectRepository; -final class StoredObjectRepository implements ObjectRepository +final readonly class StoredObjectRepository implements StoredObjectRepositoryInterface { - private readonly EntityRepository $repository; + private EntityRepository $repository; public function __construct(EntityManagerInterface $entityManager) { diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepositoryInterface.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepositoryInterface.php new file mode 100644 index 000000000..df2202b4f --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectRepositoryInterface.php @@ -0,0 +1,22 @@ + + */ +interface StoredObjectRepositoryInterface extends ObjectRepository +{ +} diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php index b55074627..b6ab798b8 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php @@ -104,6 +104,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface ) : $clearContent; + $headers = []; + + if (null !== $document->getDeleteAt()) { + $headers['X-Delete-At'] = $document->getDeleteAt()->getTimestamp(); + } + try { $response = $this ->client @@ -118,6 +124,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface ->url, [ 'body' => $encryptedContent, + 'headers' => $headers, ] ); } catch (TransportExceptionInterface $exception) { @@ -129,6 +136,11 @@ final class StoredObjectManager implements StoredObjectManagerInterface } } + public function clearCache(): void + { + $this->inMemory = []; + } + private function extractLastModifiedFromResponse(ResponseInterface $response): \DateTimeImmutable { $lastModifiedString = (($response->getHeaders()['last-modified'] ?? [])[0] ?? ''); diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php index cee6586ea..d55f68023 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Service; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Exception\StoredObjectManagerException; interface StoredObjectManagerInterface { @@ -23,6 +24,8 @@ interface StoredObjectManagerInterface * @param StoredObject $document the document * * @return string the retrieved content in clear + * + * @throws StoredObjectManagerException if unable to read or decrypt the content */ public function read(StoredObject $document): string; @@ -31,6 +34,10 @@ interface StoredObjectManagerInterface * * @param StoredObject $document the document * @param $clearContent The content to store in clear + * + * @throws StoredObjectManagerException */ public function write(StoredObject $document, string $clearContent): void; + + public function clearCache(): void; } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/StoredObjectManagerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php similarity index 81% rename from src/Bundle/ChillDocStoreBundle/Tests/StoredObjectManagerTest.php rename to src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php index bb6971939..e00582367 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/StoredObjectManagerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php @@ -9,7 +9,7 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Chill\DocStoreBundle\Tests; +namespace Chill\DocStoreBundle\Tests\Service; use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface; use Chill\DocStoreBundle\Entity\StoredObject; @@ -117,6 +117,41 @@ final class StoredObjectManagerTest extends TestCase self::assertEquals($clearContent, $storedObjectManager->read($storedObject)); } + public function testWriteWithDeleteAt() + { + $storedObject = new StoredObject(); + + $expectedRequests = [ + function ($method, $url, $options): MockResponse { + self::assertEquals('PUT', $method); + self::assertArrayHasKey('headers', $options); + self::assertIsArray($options['headers']); + self::assertCount(0, array_filter($options['headers'], fn (string $header) => str_contains($header, 'X-Delete-At'))); + + return new MockResponse('', ['http_code' => 201]); + }, + + function ($method, $url, $options): MockResponse { + self::assertEquals('PUT', $method); + self::assertArrayHasKey('headers', $options); + self::assertIsArray($options['headers']); + self::assertCount(1, array_filter($options['headers'], fn (string $header) => str_contains($header, 'X-Delete-At'))); + self::assertContains('X-Delete-At: 1711014260', $options['headers']); + + return new MockResponse('', ['http_code' => 201]); + }, + ]; + $client = new MockHttpClient($expectedRequests); + + $manager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject)); + + $manager->write($storedObject, 'ok'); + + // with a deletedAt date + $storedObject->setDeleteAt(\DateTimeImmutable::createFromFormat('U', '1711014260')); + $manager->write($storedObject, 'ok'); + } + private function getHttpClient(string $encodedContent): HttpClientInterface { $callback = static function ($method, $url, $options) use ($encodedContent) { diff --git a/src/Bundle/ChillDocStoreBundle/migrations/Version20240322100107.php b/src/Bundle/ChillDocStoreBundle/migrations/Version20240322100107.php new file mode 100644 index 000000000..5f7a92b48 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/migrations/Version20240322100107.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE chill_doc.stored_object ADD deleteAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_doc.stored_object ADD generationErrors TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('COMMENT ON COLUMN chill_doc.stored_object.deleteAt IS \'(DC2Type:datetime_immutable)\''); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_doc.stored_object DROP deleteAt'); + $this->addSql('ALTER TABLE chill_doc.stored_object DROP generationErrors'); + } +} diff --git a/src/Bundle/ChillPersonBundle/Service/DocGenerator/AccompanyingPeriodContext.php b/src/Bundle/ChillPersonBundle/Service/DocGenerator/AccompanyingPeriodContext.php index 592ab21fb..b85c2ae70 100644 --- a/src/Bundle/ChillPersonBundle/Service/DocGenerator/AccompanyingPeriodContext.php +++ b/src/Bundle/ChillPersonBundle/Service/DocGenerator/AccompanyingPeriodContext.php @@ -227,7 +227,7 @@ class AccompanyingPeriodContext implements } } - if ($options['thirdParty']) { + if ($options['thirdParty'] ?? false) { $data['thirdParty'] = $this->normalizer->normalize($contextGenerationData['thirdParty'], 'docgen', [ 'docgen:expects' => ThirdParty::class, 'groups' => 'docgen:read', diff --git a/src/Bundle/ChillWopiBundle/src/Controller/Editor.php b/src/Bundle/ChillWopiBundle/src/Controller/Editor.php index feae8d708..172729d3f 100644 --- a/src/Bundle/ChillWopiBundle/src/Controller/Editor.php +++ b/src/Bundle/ChillWopiBundle/src/Controller/Editor.php @@ -15,7 +15,6 @@ use ChampsLibres\WopiLib\Contract\Service\Configuration\ConfigurationInterface; use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface; use ChampsLibres\WopiLib\Contract\Service\DocumentManagerInterface; use Chill\DocStoreBundle\Entity\StoredObject; -use Chill\MainBundle\Entity\User; use Chill\WopiBundle\Service\Controller\ResponderInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use loophp\psr17\Psr17Interface; @@ -43,13 +42,11 @@ final readonly class Editor public function __invoke(string $fileId, Request $request): Response { - if (null === $user = $this->security->getUser()) { + if (!($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'))) { throw new AccessDeniedHttpException('Please authenticate to access this feature'); } - if (!$user instanceof User) { - throw new AccessDeniedHttpException('Please authenticate as a user to access this feature'); - } + $user = $this->security->getUser(); $configuration = $this->wopiConfiguration->jsonSerialize(); /** @var StoredObject $storedObject */ @@ -77,7 +74,12 @@ final readonly class Editor } if ([] === $discoverExtension = $this->wopiDiscovery->discoverMimeType($storedObject->getType())) { - throw new \Exception(sprintf('Unable to find mime type %s', $storedObject->getType())); + return new Response( + $this->engine + ->render('@ChillWopi/Editor/unable_to_edit_such_document.html.twig', [ + 'document' => $storedObject, + ]) + ); } $configuration['favIconUrl'] = ''; diff --git a/src/Bundle/ChillWopiBundle/src/Resources/views/Editor/unable_to_edit_such_document.html.twig b/src/Bundle/ChillWopiBundle/src/Resources/views/Editor/unable_to_edit_such_document.html.twig new file mode 100644 index 000000000..36b8cd631 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Resources/views/Editor/unable_to_edit_such_document.html.twig @@ -0,0 +1,34 @@ +{% extends '@ChillMain/layout.html.twig' %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_document_action_buttons_group') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_document_action_buttons_group') }} +{% endblock %} + +{% block content %} +
+
+

+ {{ 'wopi_editor.document unsupported for edition'|trans }} +

+
+ +
+

{{ document|chill_document_button_group(document.title|default('Document'), false) }}

+
+ +
+ + +{% endblock content %} diff --git a/src/Bundle/ChillWopiBundle/src/Service/Wopi/AuthorizationManager.php b/src/Bundle/ChillWopiBundle/src/Service/Wopi/AuthorizationManager.php index 53e9ad819..0054bda4f 100644 --- a/src/Bundle/ChillWopiBundle/src/Service/Wopi/AuthorizationManager.php +++ b/src/Bundle/ChillWopiBundle/src/Service/Wopi/AuthorizationManager.php @@ -38,7 +38,7 @@ class AuthorizationManager implements \ChampsLibres\WopiBundle\Contracts\Authori $user = $this->security->getUser(); - if (!$user instanceof User) { + if (!($user instanceof User || $this->security->isGranted('ROLE_ADMIN'))) { return false; } diff --git a/src/Bundle/ChillWopiBundle/src/Service/Wopi/UserManager.php b/src/Bundle/ChillWopiBundle/src/Service/Wopi/UserManager.php index 4a0857521..25a6f7c8d 100644 --- a/src/Bundle/ChillWopiBundle/src/Service/Wopi/UserManager.php +++ b/src/Bundle/ChillWopiBundle/src/Service/Wopi/UserManager.php @@ -25,6 +25,10 @@ class UserManager implements \ChampsLibres\WopiBundle\Contracts\UserManagerInter { $user = $this->security->getUser(); + if (!$user instanceof User && $this->security->isGranted('ROLE_ADMIN')) { + return $user->getUsername(); + } + if (!$user instanceof User) { return null; } @@ -36,6 +40,10 @@ class UserManager implements \ChampsLibres\WopiBundle\Contracts\UserManagerInter { $user = $this->security->getUser(); + if (!$user instanceof User && $this->security->isGranted('ROLE_ADMIN')) { + return $user->getUsername(); + } + if (!$user instanceof User) { return null; } diff --git a/src/Bundle/ChillWopiBundle/src/translations/messages.fr.yml b/src/Bundle/ChillWopiBundle/src/translations/messages.fr.yml new file mode 100644 index 000000000..2875550a1 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/translations/messages.fr.yml @@ -0,0 +1,2 @@ +wopi_editor: + document unsupported for edition: Ce format de document n'est pas éditable