mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 01:08:26 +00:00 
			
		
		
		
	Improve admin UX for configuration of document template (document generation)
This commit is contained in:
		
							
								
								
									
										5
									
								
								.changes/unreleased/Feature-20240326-170418.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.changes/unreleased/Feature-20240326-170418.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||
| @@ -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, | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|   | ||||
| @@ -69,7 +69,7 @@ class DocGeneratorTemplate | ||||
|      * | ||||
|      * @Serializer\Groups({"read"}) | ||||
|      */ | ||||
|     private int $id; | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @ORM\Column(type="json") | ||||
|   | ||||
| @@ -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; | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,23 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Repository; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| /** | ||||
|  * @extends ObjectRepository<DocGeneratorTemplate> | ||||
|  */ | ||||
| interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository | ||||
| { | ||||
|     public function countByEntity(string $entity): int; | ||||
| } | ||||
| @@ -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 %} | ||||
|                 <p class="chill-no-data-statement">{{ 'docgen.Any template configured'|trans }}</p> | ||||
|             {% else %} | ||||
|                 <div class="flex-table"> | ||||
|                     {% for entity in entities %} | ||||
|                             <div class="item-bloc"> | ||||
|                                 <div class="item-row"> | ||||
|                                     <div class="item-col" style="flex-basis:100%;"> | ||||
|                                         <h2>{{ entity.name|localize_translatable_string }}</h2> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="item-row"> | ||||
|                                     <p><span class="badge bg-chill-green-dark">{{ contextManager.getContextByKey(entity.context).name|trans }}</span></p> | ||||
|                                 </div> | ||||
|                                 <div class="item-row"> | ||||
|                                     <div class="item-col"></div> | ||||
|                                     <ul class="record_actions item-col flex-shrink-1"> | ||||
|                                         <li> | ||||
|                                             <form method="get" action="{{ path('chill_docgenerator_test_generate_redirect') }}"> | ||||
|                                                 <input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', app.request.uri)|e('html_attr') }}" /> | ||||
|                                                 <input type="hidden" name="template" value="{{ entity.id|e('html_attr') }}" /> | ||||
|                                                 <input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" /> | ||||
|                                                 <input type="text" name="entityId" placeholder="{{ 'docgen.entity_id_placeholder'|trans }}" required /> | ||||
|  | ||||
|                                                 <button type="submit" class="btn btn-mini btn-misc"><i class="fa fa-cog"></i>{{ 'docgen.test generate'|trans }}</button> | ||||
|                                             </form> | ||||
|                                         </li> | ||||
|                                         <li> | ||||
|                                             {{ entity.file|chill_document_button_group('Template file', true) }} | ||||
|                                         </li> | ||||
|                                         <li> | ||||
|                                             <a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a> | ||||
|                                         </li> | ||||
|                                     </ul> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                     {% endfor %} | ||||
|                 </div> | ||||
|             {% endif %} | ||||
|  | ||||
|  | ||||
|             {% for entity in entities %} | ||||
|                 <tr> | ||||
|                     <td>{{ entity.id }}</td> | ||||
| @@ -18,7 +70,7 @@ | ||||
|                     <td>{{ contextManager.getContextByKey(entity.context).name|trans }}</td> | ||||
|                     <td> | ||||
|                         <form method="get" action="{{ path('chill_docgenerator_test_generate_redirect') }}"> | ||||
|                             <input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', '/')|e('html_attr') }}" /> | ||||
|                             <input type="hidden" name="returnPath" value="{{ app.request.query.get('returnPath', app.request.uri)|e('html_attr') }}" /> | ||||
|                             <input type="hidden" name="template" value="{{ entity.id|e('html_attr') }}" /> | ||||
|                             <input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" /> | ||||
|                             <input type="text" name="entityId" /> | ||||
| @@ -27,7 +79,14 @@ | ||||
|                         </form> | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         <a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a> | ||||
|                         <ul class="record_actions"> | ||||
|                             <li> | ||||
|                                 {{ entity.file|chill_document_button_group('Template file', true, {small: true}) }} | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{{ chill_path_add_return_path('chill_crud_docgen_template_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"></a> | ||||
|                             </li> | ||||
|                         </ul> | ||||
|                     </td> | ||||
|                 </tr> | ||||
|             {% endfor %} | ||||
|   | ||||
| @@ -6,18 +6,20 @@ | ||||
| <div class="col-md-10 col-xxl"> | ||||
|  | ||||
|     <h1>{{ block('title') }}</h1> | ||||
|    <div class="container"> | ||||
|    <div class="container overflow-hidden"> | ||||
|        {% for key, context in contexts %} | ||||
|             <div class="row"> | ||||
|                 <div class="col-md-4"> | ||||
|             <div class="row g-3" style="margin-top: 1rem;"> | ||||
|                 <div class="col-4 offset-1 text-center"> | ||||
|                     <a | ||||
|                         href="{{ path('chill_crud_docgen_template_new', { 'context': key }) }}" | ||||
|                         class="btn btn-outline-chill-green-dark"> | ||||
|                         {{ context.name|trans }} | ||||
|                     </a> | ||||
|                 </div> | ||||
|                 <div class="col-md-8"> | ||||
|                    {{ context.description|trans|nl2br }} | ||||
|                 <div class="col"> | ||||
|                     <div> | ||||
|                         {{ context.description|trans|nl2br }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|        {% endfor %} | ||||
|   | ||||
| @@ -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 }} | ||||
|  | ||||
|   | ||||
| @@ -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}) }} | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,64 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\Service\Messenger; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||||
| use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; | ||||
| use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent; | ||||
|  | ||||
| /** | ||||
|  * The OnAfterMessageHandledClearStoredObjectCache class is an event subscriber that clears the stored object cache | ||||
|  * after a specific message is handled or fails. | ||||
|  */ | ||||
| final readonly class OnAfterMessageHandledClearStoredObjectCache implements EventSubscriberInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private StoredObjectManagerInterface $storedObjectManager, | ||||
|         private LoggerInterface $logger, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public static function getSubscribedEvents() | ||||
|     { | ||||
|         return [ | ||||
|             WorkerMessageHandledEvent::class => [ | ||||
|                 ['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(); | ||||
|     } | ||||
| } | ||||
| @@ -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([ | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,107 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\tests\Service\Messenger; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\DocGeneratorBundle\Service\Messenger\OnAfterMessageHandledClearStoredObjectCache; | ||||
| use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Psr\Log\NullLogger; | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; | ||||
| use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class OnAfterMessageHandledClearStoredObjectCacheTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     public function testThatNotGenerationMessageDoesNotCallAClearCache(): void | ||||
|     { | ||||
|         $storedObjectManager = $this->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'); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,226 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocGeneratorBundle\tests\Service\Messenger; | ||||
|  | ||||
| use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate; | ||||
| use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface; | ||||
| use Chill\DocGeneratorBundle\Service\Messenger\OnGenerationFails; | ||||
| use Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Repository\UserRepositoryInterface; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\Argument; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Psr\Log\NullLogger; | ||||
| use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; | ||||
| use Symfony\Component\Mime\Email; | ||||
| use Symfony\Component\Mime\RawMessage; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class OnGenerationFailsTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     public function testNotConcernedMessageAreNotHandled(): void | ||||
|     { | ||||
|         $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(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()); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,4 @@ | ||||
| docgen: | ||||
|     data_dump_email: | ||||
|         link_valid_until: >- | ||||
|             Ce lien est valide jusqu'au {validity, date, full}, {validity, time, medium} | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|     { | ||||
|   | ||||
| @@ -0,0 +1,22 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| /** | ||||
|  * @extends ObjectRepository<StoredObject> | ||||
|  */ | ||||
| interface StoredObjectRepositoryInterface extends ObjectRepository | ||||
| { | ||||
| } | ||||
| @@ -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] ?? ''); | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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) { | ||||
| @@ -0,0 +1,36 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\Migrations\DocStore; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20240322100107 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'StoredObject: add deleteAt and generationErrors columns'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->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'); | ||||
|     } | ||||
| } | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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'] = ''; | ||||
|   | ||||
| @@ -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 %} | ||||
|     <div class="alert alert-danger text-center"> | ||||
|         <div> | ||||
|             <p> | ||||
|                 {{ 'wopi_editor.document unsupported for edition'|trans }} | ||||
|             </p> | ||||
|         </div> | ||||
|  | ||||
|         <div> | ||||
|             <p>{{ document|chill_document_button_group(document.title|default('Document'), false) }}</p> | ||||
|         </div> | ||||
|  | ||||
|     </div> | ||||
|  | ||||
|     <ul class="sticky-form-buttons record_actions"> | ||||
|         <li class="cancel"> | ||||
|             <a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel"> | ||||
|                 {{ 'Cancel'|trans|chill_return_path_label }} | ||||
|             </a> | ||||
|         </li> | ||||
|     </ul> | ||||
| {% endblock content %} | ||||
| @@ -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; | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,2 @@ | ||||
| wopi_editor: | ||||
|     document unsupported for edition: Ce format de document n'est pas éditable | ||||
		Reference in New Issue
	
	Block a user