diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index ff43cf1ed..39eab3875 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -63,6 +63,7 @@ framework: 'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async 'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async 'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority + 'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async # end of routes added by chill-bundles recipes # Route your messages to the transports # 'App\Message\YourMessage': async diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php index 951234e29..15a3e7e2e 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/BySocialIssueFilter.php @@ -12,17 +12,21 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Export\Filter\ACPFilters; use Chill\ActivityBundle\Export\Declarations; +use Chill\MainBundle\Export\ExportDataNormalizerTrait; use Chill\MainBundle\Export\ExportGenerationContext; use Chill\MainBundle\Export\FilterInterface; use Chill\PersonBundle\Entity\SocialWork\SocialIssue; use Chill\PersonBundle\Form\Type\PickSocialIssueType; +use Chill\PersonBundle\Repository\SocialWork\SocialIssueRepository; use Chill\PersonBundle\Templating\Entity\SocialIssueRender; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; class BySocialIssueFilter implements FilterInterface { - public function __construct(private readonly SocialIssueRender $issueRender) {} + use ExportDataNormalizerTrait; + + public function __construct(private readonly SocialIssueRender $issueRender, private readonly SocialIssueRepository $issueRepository) {} public function addRole(): ?string { @@ -63,12 +67,12 @@ class BySocialIssueFilter implements FilterInterface public function normalizeFormData(array $formData): array { - return ['accepted_socialissues' => $formData['accepted_socialissues']]; + return ['accepted_socialissues' => $this->normalizeDoctrineEntity($formData['accepted_socialissues'])]; } public function denormalizeFormData(array $formData, int $fromVersion): array { - return ['accepted_socialissues' => $formData['accepted_socialissues']]; + return ['accepted_socialissues' => $this->denormalizeDoctrineEntity($formData['accepted_socialissues'], $this->issueRepository)]; } public function getFormDefaultData(): array diff --git a/src/Bundle/ChillMainBundle/Controller/ExportController.php b/src/Bundle/ChillMainBundle/Controller/ExportController.php index 0153423a1..c06478717 100644 --- a/src/Bundle/ChillMainBundle/Controller/ExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/ExportController.php @@ -316,13 +316,14 @@ class ExportController extends AbstractController $savedExport, ); - $this->entityManager->persist( - $exportGeneration = new ExportGeneration( - $alias, - $this->exportConfigNormalizer->normalizeConfig($alias, $dataToNormalize), - $this->clock->now()->add(new \DateInterval('P6M')), - ), - ); + $deleteAt = $this->clock->now()->add(new \DateInterval('P6M')); + $options = $this->exportConfigNormalizer->normalizeConfig($alias, $dataToNormalize); + $exportGeneration = match (null === $savedExport) { + true => new ExportGeneration($alias, $options, $deleteAt), + false => ExportGeneration::fromSavedExport($savedExport, $deleteAt, $options), + }; + + $this->entityManager->persist($exportGeneration); $this->entityManager->flush(); $this->messageBus->dispatch(new ExportRequestGenerationMessage($exportGeneration, $user)); diff --git a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php index cb9d89215..20b9bf1c5 100644 --- a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php @@ -14,13 +14,9 @@ namespace Chill\MainBundle\Controller; use Chill\MainBundle\Entity\ExportGeneration; use Chill\MainBundle\Entity\SavedExport; use Chill\MainBundle\Entity\User; -use Chill\MainBundle\Export\ExportInterface; +use Chill\MainBundle\Export\ExportDescriptionHelper; use Chill\MainBundle\Export\ExportManager; -use Chill\MainBundle\Export\GroupedExportInterface; use Chill\MainBundle\Form\SavedExportType; -use Chill\MainBundle\Repository\ExportGenerationRepository; -use Chill\MainBundle\Repository\SavedExportRepositoryInterface; -use Chill\MainBundle\Security\Authorization\ChillExportVoter; use Chill\MainBundle\Security\Authorization\ExportGenerationVoter; use Chill\MainBundle\Security\Authorization\SavedExportVoter; use Doctrine\ORM\EntityManagerInterface; @@ -46,11 +42,10 @@ final readonly class SavedExportController private EntityManagerInterface $entityManager, private ExportManager $exportManager, private FormFactoryInterface $formFactory, - private SavedExportRepositoryInterface $savedExportRepository, private Security $security, private TranslatorInterface $translator, private UrlGeneratorInterface $urlGenerator, - private ExportGenerationRepository $exportGenerationRepository, + private ExportDescriptionHelper $exportDescriptionHelper, ) {} #[Route(path: '/{_locale}/exports/saved/{id}/delete', name: 'chill_main_export_saved_delete')] @@ -114,21 +109,35 @@ final readonly class SavedExportController $request->query->has('title') ? $request->query->get('title') : $title ); - return $this->handleEdit($savedExport, $request); + if ($exportGeneration->isLinkedToSavedExport()) { + $savedExport->setDescription($exportGeneration->getSavedExport()->getDescription()); + } else { + $savedExport->setDescription( + implode( + "\n", + array_map( + fn (string $item) => '- '.$item."\n", + $this->exportDescriptionHelper->describe($savedExport->getExportAlias(), $savedExport->getOptions(), includeExportTitle: false) + ) + ) + ); + } + + return $this->handleEdit($savedExport, $request, true); } #[Route(path: '/exports/saved/duplicate-from-saved-export/{id}/new', name: 'chill_main_export_saved_duplicate')] public function duplicate(SavedExport $previousSavedExport, Request $request): Response { - if (!$this->security->isGranted(SavedExportVoter::GENERATE, $previousSavedExport)) { - throw new AccessDeniedHttpException('Not allowed to see this saved export'); - } - $user = $this->security->getUser(); if (!$user instanceof User) { throw new AccessDeniedHttpException('only regular user can create a saved export'); } + if (!$this->security->isGranted(SavedExportVoter::EDIT, $previousSavedExport)) { + throw new AccessDeniedHttpException('Not allowed to edit this saved export'); + } + $savedExport = new SavedExport(); $savedExport ->setExportAlias($previousSavedExport->getExportAlias()) @@ -145,7 +154,7 @@ final readonly class SavedExportController } - private function handleEdit(SavedExport $savedExport, Request $request): Response + private function handleEdit(SavedExport $savedExport, Request $request, bool $showWarningAutoGeneratedDescription = false): Response { $form = $this->formFactory->create(SavedExportType::class, $savedExport); $form->handleRequest($request); @@ -168,6 +177,7 @@ final readonly class SavedExportController '@ChillMain/SavedExport/new.html.twig', [ 'form' => $form->createView(), + 'showWarningAutoGeneratedDescription' => $showWarningAutoGeneratedDescription, ], ), ); @@ -209,7 +219,7 @@ final readonly class SavedExportController #[Route(path: '/{_locale}/exports/saved/{savedExport}/edit-options/{exportGeneration}', name: 'chill_main_export_saved_options_edit')] public function updateOptionsFromGeneration(SavedExport $savedExport, ExportGeneration $exportGeneration, Request $request): Response { - if (!$this->security->isGranted(SavedExportVoter::EDIT, $savedExport)) { + if (!$this->security->isGranted(SavedExportVoter::DUPLICATE, $savedExport)) { throw new AccessDeniedHttpException('You are not allowed to access this saved export'); } @@ -234,52 +244,4 @@ final readonly class SavedExportController $this->urlGenerator->generate('chill_main_export_saved_edit', ['id' => $savedExport->getId()]), ); } - - #[Route(path: '/{_locale}/exports/saved/my', name: 'chill_main_export_saved_list_my')] - public function list(): Response - { - $user = $this->security->getUser(); - - if (!$this->security->isGranted(ChillExportVoter::GENERATE_SAVED_EXPORT) || !$user instanceof User) { - throw new AccessDeniedHttpException(sprintf('Missing role: %s', ChillExportVoter::GENERATE_SAVED_EXPORT)); - } - - $exports = array_filter( - $this->savedExportRepository->findSharedWithUser($user, ['exportAlias' => 'ASC', 'title' => 'ASC']), - fn (SavedExport $savedExport): bool => $this->security->isGranted(SavedExportVoter::GENERATE, $savedExport), - ); - - // group by center - /** @var array $exportsGrouped */ - $exportsGrouped = []; - - foreach ($exports as $savedExport) { - $export = $this->exportManager->getExport($savedExport->getExportAlias()); - - $exportsGrouped[ - $export instanceof GroupedExportInterface - ? $this->translator->trans($export->getGroup()) : '_' - ][] = ['saved' => $savedExport, 'export' => $export]; - } - - ksort($exportsGrouped); - - // get last executions - $lastExecutions = []; - foreach ($exports as $savedExport) { - $lastExecutions[$savedExport->getId()->toString()] = $this->exportGenerationRepository - ->findExportGenerationBySavedExportAndUser($savedExport, $user, 5); - } - - return new Response( - $this->templating->render( - '@ChillMain/SavedExport/index.html.twig', - [ - 'grouped_exports' => $exportsGrouped, - 'total' => \count($exports), - 'last_executions' => $lastExecutions, - ], - ), - ); - } } diff --git a/src/Bundle/ChillMainBundle/Controller/SavedExportIndexController.php b/src/Bundle/ChillMainBundle/Controller/SavedExportIndexController.php new file mode 100644 index 000000000..82e9913da --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/SavedExportIndexController.php @@ -0,0 +1,104 @@ +security->getUser(); + + if (!$this->security->isGranted(ChillExportVoter::GENERATE_SAVED_EXPORT) || !$user instanceof User) { + throw new AccessDeniedHttpException(sprintf('Missing role: %s', ChillExportVoter::GENERATE_SAVED_EXPORT)); + } + + $filter = $this->buildFilter(); + + $filterParams = []; + if ('' !== $filter->getQueryString() && null !== $filter->getQueryString()) { + $filterParams[SavedExportRepositoryInterface::FILTER_DESCRIPTION | SavedExportRepositoryInterface::FILTER_TITLE] = $filter->getQueryString(); + } + + $exports = array_filter( + $this->savedExportRepository->findSharedWithUser($user, ['exportAlias' => 'ASC', 'title' => 'ASC'], filters: $filterParams), + fn (SavedExport $savedExport): bool => $this->security->isGranted(SavedExportVoter::GENERATE, $savedExport), + ); + + // group by center + /** @var array $exportsGrouped */ + $exportsGrouped = []; + + foreach ($exports as $savedExport) { + $export = $this->exportManager->getExport($savedExport->getExportAlias()); + + $exportsGrouped[$export instanceof GroupedExportInterface + ? $this->translator->trans($export->getGroup()) : '_'][] = ['saved' => $savedExport, 'export' => $export]; + } + + ksort($exportsGrouped); + + // get last executions + $lastExecutions = []; + foreach ($exports as $savedExport) { + $lastExecutions[$savedExport->getId()->toString()] = $this->exportGenerationRepository + ->findExportGenerationBySavedExportAndUser($savedExport, $user, 5); + } + + return new Response( + $this->templating->render( + '@ChillMain/SavedExport/index.html.twig', + [ + 'grouped_exports' => $exportsGrouped, + 'total' => \count($exports), + 'last_executions' => $lastExecutions, + 'filter' => $filter, + ], + ), + ); + } + + private function buildFilter(): FilterOrderHelper + { + $filter = $this->filterOrderHelperFactory->create('saved-export-index-filter'); + $filter->addSearchBox(); + + return $filter->build(); + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/ExportGeneration.php b/src/Bundle/ChillMainBundle/Entity/ExportGeneration.php index ede2943bd..a8d4e274b 100644 --- a/src/Bundle/ChillMainBundle/Entity/ExportGeneration.php +++ b/src/Bundle/ChillMainBundle/Entity/ExportGeneration.php @@ -54,6 +54,13 @@ class ExportGeneration implements TrackCreationInterface private array $options = [], #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)] private ?\DateTimeImmutable $deleteAt = null, + + /** + * The related saved export. + * + * Note that, in some case, the options of this ExportGenration are not equals to the options of the saved export. + * This happens when the options of the saved export are updated. + */ #[ORM\ManyToOne(targetEntity: SavedExport::class)] #[ORM\JoinColumn(nullable: true)] private ?SavedExport $savedExport = null, @@ -118,9 +125,24 @@ class ExportGeneration implements TrackCreationInterface return null !== $this->savedExport; } - public static function fromSavedExport(SavedExport $savedExport, ?\DateTimeImmutable $deletedAt = null): self + /** + * Compares the options of the saved export and the current export generation. + * + * Return false if the current export generation's options are not equal to the one in the saved export. This may + * happens when we update the configuration of a saved export. + */ + public function isConfigurationDifferentFromSavedExport(): bool { - $generation = new self($savedExport->getExportAlias(), $savedExport->getOptions(), $deletedAt, $savedExport); + if (!$this->isLinkedToSavedExport()) { + return false; + } + + return $this->savedExport->getOptions() !== $this->getOptions(); + } + + public static function fromSavedExport(SavedExport $savedExport, ?\DateTimeImmutable $deletedAt = null, ?array $overrideOptions = null): self + { + $generation = new self($savedExport->getExportAlias(), $overrideOptions ?? $savedExport->getOptions(), $deletedAt, $savedExport); $generation->getStoredObject()->setTitle($savedExport->getTitle()); return $generation; diff --git a/src/Bundle/ChillMainBundle/Export/Cronjob/RemoveExpiredExportGenerationCronJob.php b/src/Bundle/ChillMainBundle/Export/Cronjob/RemoveExpiredExportGenerationCronJob.php new file mode 100644 index 000000000..2f15bf095 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/Cronjob/RemoveExpiredExportGenerationCronJob.php @@ -0,0 +1,52 @@ +getLastStart()->getTimestamp() < $this->clock->now()->sub(new \DateInterval('PT24H'))->getTimestamp(); + } + + public function getKey(): string + { + return self::KEY; + } + + public function run(array $lastExecutionData): ?array + { + $now = $this->clock->now(); + + foreach ($this->exportGenerationRepository->findExpiredExportGeneration($now) as $exportGeneration) { + $this->messageBus->dispatch(new Envelope(new RemoveExportGenerationMessage($exportGeneration))); + } + + return ['last-deletion' => $now->getTimestamp()]; + } +} diff --git a/src/Bundle/ChillMainBundle/Export/ExportDescriptionHelper.php b/src/Bundle/ChillMainBundle/Export/ExportDescriptionHelper.php new file mode 100644 index 000000000..3016f2f79 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/ExportDescriptionHelper.php @@ -0,0 +1,74 @@ + + */ + public function describe(string $exportAlias, array $exportOptions, bool $includeExportTitle = true): array + { + $output = []; + $denormalized = $this->exportConfigNormalizer->denormalizeConfig($exportAlias, $exportOptions); + $user = $this->security->getUser(); + + if ($includeExportTitle) { + $output[] = $this->trans($this->exportManager->getExport($exportAlias)->getTitle()); + } + + if (!$user instanceof User) { + return $output; + } + $context = new ExportGenerationContext($user); + + foreach ($this->exportConfigProcessor->retrieveUsedFilters($denormalized['filters']) as $name => $filter) { + $output[] = $this->trans($filter->describeAction($denormalized['filters'][$name]['form'], $context)); + } + + foreach ($this->exportConfigProcessor->retrieveUsedAggregators($denormalized['aggregators']) as $name => $aggregator) { + $output[] = $this->trans($aggregator->getTitle()); + } + + return $output; + } + + private function trans(string|TranslatableInterface|array $translatable): string + { + if (is_string($translatable)) { + return $this->translator->trans($translatable); + } + + if ($translatable instanceof TranslatableInterface) { + return $translatable->trans($this->translator); + } + + // array case + return $this->translator->trans($translatable[0], $translatable[1] ?? []); + } +} diff --git a/src/Bundle/ChillMainBundle/Export/Messenger/RemoveExportGenerationMessage.php b/src/Bundle/ChillMainBundle/Export/Messenger/RemoveExportGenerationMessage.php new file mode 100644 index 000000000..31e68a82d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/Messenger/RemoveExportGenerationMessage.php @@ -0,0 +1,24 @@ +exportGenerationId = $exportGeneration->getId()->toString(); + } +} diff --git a/src/Bundle/ChillMainBundle/Export/Messenger/RemoveExportGenerationMessageHandler.php b/src/Bundle/ChillMainBundle/Export/Messenger/RemoveExportGenerationMessageHandler.php new file mode 100644 index 000000000..e3e3063ca --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/Messenger/RemoveExportGenerationMessageHandler.php @@ -0,0 +1,49 @@ +exportGenerationRepository->find($message->exportGenerationId); + + if (null === $exportGeneration) { + $this->logger->error(self::LOG_PREFIX.'ExportGeneration not found'); + throw new UnrecoverableMessageHandlingException(self::LOG_PREFIX.'ExportGeneration not found'); + } + + $storedObject = $exportGeneration->getStoredObject(); + $storedObject->setDeleteAt($this->clock->now()); + + $this->entityManager->remove($exportGeneration); + $this->entityManager->flush(); + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/ExportGenerationRepository.php b/src/Bundle/ChillMainBundle/Repository/ExportGenerationRepository.php index 7b6cbf13c..bb70a22ba 100644 --- a/src/Bundle/ChillMainBundle/Repository/ExportGenerationRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/ExportGenerationRepository.php @@ -73,4 +73,13 @@ class ExportGenerationRepository extends ServiceEntityRepository implements Asso ->getQuery() ->getResult(); } + + public function findExpiredExportGeneration(\DateTimeImmutable $atDate): iterable + { + return $this->createQueryBuilder('e') + ->where('e.deleteAt < :atDate') + ->setParameter('atDate', $atDate) + ->getQuery() + ->toIterable(); + } } diff --git a/src/Bundle/ChillMainBundle/Repository/SavedExportRepository.php b/src/Bundle/ChillMainBundle/Repository/SavedExportRepository.php index 63b68bd5d..9b0d5a2ef 100644 --- a/src/Bundle/ChillMainBundle/Repository/SavedExportRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/SavedExportRepository.php @@ -18,6 +18,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ObjectRepository; +use Symfony\Component\String\UnicodeString; /** * @implements ObjectRepository @@ -60,7 +61,7 @@ class SavedExportRepository implements SavedExportRepositoryInterface return $this->prepareResult($qb, $orderBy, $limit, $offset); } - public function findSharedWithUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array + public function findSharedWithUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null, array $filters = []): array { $qb = $this->repository->createQueryBuilder('se'); @@ -76,6 +77,27 @@ class SavedExportRepository implements SavedExportRepositoryInterface ) ->setParameter('user', $user); + foreach ($filters as $key => $filter) { + if (self::FILTER_TITLE === ($key & self::FILTER_TITLE) + || self::FILTER_DESCRIPTION === ($key & self::FILTER_DESCRIPTION)) { + $filter = new UnicodeString($filter); + + $i = 0; + foreach ($filter->split(' ') as $word) { + $orx = $qb->expr()->orX(); + if (self::FILTER_TITLE === ($key & self::FILTER_TITLE)) { + $orx->add($qb->expr()->like('LOWER(se.title)', 'LOWER(:qs'.$i.')')); + } + if (self::FILTER_DESCRIPTION === ($key & self::FILTER_DESCRIPTION)) { + $orx->add($qb->expr()->like('LOWER(se.description)', 'LOWER(:qs'.$i.')')); + } + $qb->andWhere($orx); + $qb->setParameter('qs'.$i, '%'.$word->trim().'%'); + ++$i; + } + } + } + return $this->prepareResult($qb, $orderBy, $limit, $offset); } diff --git a/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php index d9bc1d42e..c62e0f29c 100644 --- a/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php +++ b/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php @@ -20,6 +20,9 @@ use Doctrine\Persistence\ObjectRepository; */ interface SavedExportRepositoryInterface extends ObjectRepository { + public const FILTER_TITLE = 0x01; + public const FILTER_DESCRIPTION = 0x10; + public function find($id): ?SavedExport; /** @@ -34,7 +37,14 @@ interface SavedExportRepositoryInterface extends ObjectRepository */ public function findByUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array; - public function findSharedWithUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array; + /** + * Get the saved export created by and the user and the ones shared with the user. + * + * @param array $filters filters where keys are one of the constant starting with FILTER_ + * + * @return list + */ + public function findSharedWithUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null, array $filters = []): array; public function findOneBy(array $criteria): ?SavedExport; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue index 850db686c..dde8c2dca 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue @@ -13,6 +13,7 @@
  • - {% if not exportGeneration.isLinkedToSavedExport %} + {% if not exportGeneration.linkedToSavedExport %}
  • {{ 'Save'|trans }}
  • {% else %} -
  • - +
  • + {% endif %} {% endif %} {% endblock content %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig index 4a9b9490e..49bfda9e8 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig @@ -18,6 +18,7 @@ {% block title %}{{ 'saved_export.Saved exports'|trans }}{% endblock %} + {% macro render_export_card(saved, export, export_alias, generations) %}
    @@ -30,6 +31,10 @@

    {% if app.user is same as saved.user %}{{ 'saved_export.Owner'|trans }}{% endif %}

    + {% else %} +

    + Partagé par {{ saved.user|chill_entity_render_box }} +

    {% endif %}

    {{ saved.description|chill_markdown_to_html }}

    @@ -63,7 +68,9 @@ {% endif %} {# reminder: the controller already checked that the user can generate saved exports #}
  • {{ 'saved_export.update_filters_aggregators_and_execute'|trans }}
  • -
  • {{ 'saved_export.Duplicate'|trans }}
  • + {% if is_granted('CHILL_MAIN_EXPORT_SAVED_DUPLICATE', saved) %} +
  • {{ 'saved_export.Duplicate'|trans }}
  • + {% endif %} {% if is_granted('CHILL_MAIN_EXPORT_SAVED_DELETE', saved) %}
  • {{ 'Delete'|trans }}
  • {% endif %} @@ -82,6 +89,7 @@ {{ include('@ChillMain/Export/_navbar.html.twig', {'current' : 'my'}) }}
    + {{ filter|chill_render_filter_order_helper }} {% if total == 0 %}

    {{ 'saved_export.Any saved export'|trans }}

    diff --git a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/new.html.twig b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/new.html.twig index 3fddcd270..d6e3e3eee 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/new.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/new.html.twig @@ -18,6 +18,13 @@ {{ form_start(form) }} {{ form_row(form.title) }} + + {% if showWarningAutoGeneratedDescription|default(false) %} + + {% endif %} + {{ form_row(form.description) }} {% if form.share is defined %} diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/SavedExportVoter.php b/src/Bundle/ChillMainBundle/Security/Authorization/SavedExportVoter.php index 1d686dee4..aaf8b8010 100644 --- a/src/Bundle/ChillMainBundle/Security/Authorization/SavedExportVoter.php +++ b/src/Bundle/ChillMainBundle/Security/Authorization/SavedExportVoter.php @@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\SavedExport; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Export\ExportManager; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; final class SavedExportVoter extends Voter @@ -25,6 +26,8 @@ final class SavedExportVoter extends Voter final public const GENERATE = 'CHILL_MAIN_EXPORT_SAVED_GENERATE'; + final public const DUPLICATE = 'CHILL_MAIN_EXPORT_SAVED_DUPLICATE'; + final public const SHARE = 'CHILL_MAIN_EXPORT_SAVED_SHARE'; private const ALL = [ @@ -32,9 +35,10 @@ final class SavedExportVoter extends Voter self::EDIT, self::GENERATE, self::SHARE, + self::DUPLICATE, ]; - public function __construct(private readonly ExportManager $exportManager) {} + public function __construct(private readonly ExportManager $exportManager, private readonly AccessDecisionManagerInterface $accessDecisionManager) {} protected function supports($attribute, $subject): bool { @@ -52,6 +56,7 @@ final class SavedExportVoter extends Voter return match ($attribute) { self::DELETE, self::EDIT, self::SHARE => $subject->getUser() === $token->getUser(), + self::DUPLICATE => $this->accessDecisionManager->decide($token, [ChillExportVoter::COMPOSE_EXPORT]) && $this->accessDecisionManager->decide($token, [self::EDIT], $subject) , self::GENERATE => $this->canUserGenerate($user, $subject), default => throw new \UnexpectedValueException('attribute not supported: '.$attribute), }; diff --git a/src/Bundle/ChillMainBundle/Service/UserGroup/UserGroupRelatedToUserJobSync.php b/src/Bundle/ChillMainBundle/Service/UserGroup/UserGroupRelatedToUserJobSync.php index d54f089d2..fd030bb16 100644 --- a/src/Bundle/ChillMainBundle/Service/UserGroup/UserGroupRelatedToUserJobSync.php +++ b/src/Bundle/ChillMainBundle/Service/UserGroup/UserGroupRelatedToUserJobSync.php @@ -87,8 +87,8 @@ final readonly class UserGroupRelatedToUserJobSync implements UserGroupRelatedTo AND chill_main_user_group_user.user_id = u.id AND jh.user_id = u.id AND tsrange(jh.startdate, jh.enddate) @> localtimestamp -- only when the user's jobid is different than the user_group id - AND ug.userjob_id IS NOT NULL - AND jh.job_id <> ug.userjob_id + -- or where the user.enabled is null + AND ((ug.userjob_id IS NOT NULL AND jh.job_id <> ug.userjob_id) OR u.enabled IS NULL) SQL; $result = $connection->executeQuery($sql); @@ -103,7 +103,8 @@ final readonly class UserGroupRelatedToUserJobSync implements UserGroupRelatedTo SELECT cmug.id, jh.user_id FROM chill_main_user_group cmug JOIN chill_main_user_job_history jh ON jh.job_id = cmug.userjob_id AND tsrange(jh.startdate, jh.enddate) @> localtimestamp - WHERE cmug.userjob_id IS NOT NULL + JOIN users u ON u.id = jh.user_id + WHERE cmug.userjob_id IS NOT NULL AND u.enabled IS TRUE ON CONFLICT DO NOTHING SQL; diff --git a/src/Bundle/ChillMainBundle/Tests/Export/Cronjob/RemoveExpiredExportGenerationCronJobTest.php b/src/Bundle/ChillMainBundle/Tests/Export/Cronjob/RemoveExpiredExportGenerationCronJobTest.php new file mode 100644 index 000000000..21c47aaf0 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Export/Cronjob/RemoveExpiredExportGenerationCronJobTest.php @@ -0,0 +1,118 @@ +prophesize(ExportGenerationRepository::class); + $bus = $this->prophesize(MessageBusInterface::class); + + $cronJob = new RemoveExpiredExportGenerationCronJob( + $clock, + $repo->reveal(), + $bus->reveal() + ); + + $this->assertTrue($cronJob->canRun(null)); + } + + public function testCanRunReturnsTrueWhenLastStartIsOlderThan24Hours() + { + $clock = new MockClock(new \DateTimeImmutable('2024-06-25 10:00:00')); + $repo = $this->prophesize(ExportGenerationRepository::class); + $bus = $this->prophesize(MessageBusInterface::class); + + $cronJob = new RemoveExpiredExportGenerationCronJob( + $clock, + $repo->reveal(), + $bus->reveal() + ); + + $execution = new CronJobExecution('remove-expired-export-generation'); + $execution->setLastStart(new \DateTimeImmutable('2024-06-24 09:59:59')); + + $this->assertTrue($cronJob->canRun($execution)); + } + + public function testCanRunReturnsFalseWhenLastStartIsWithin24Hours() + { + $clock = new MockClock(new \DateTimeImmutable('2024-06-25 10:00:00')); + $repo = $this->prophesize(ExportGenerationRepository::class); + $bus = $this->prophesize(MessageBusInterface::class); + + $cronJob = new RemoveExpiredExportGenerationCronJob( + $clock, + $repo->reveal(), + $bus->reveal() + ); + + $execution = new CronJobExecution('remove-expired-export-generation'); + $execution->setLastStart(new \DateTimeImmutable('2024-06-24 10:01:00')); + + $this->assertFalse($cronJob->canRun($execution)); + } + + public function testRunDispatchesMessagesForExpiredExportsAndReturnsLastDeletion() + { + $clock = new MockClock(new \DateTimeImmutable('2024-06-25 11:21:00')); + $repo = $this->prophesize(ExportGenerationRepository::class); + $bus = $this->prophesize(MessageBusInterface::class); + + $expiredExports = [ + new ExportGeneration('dummy', []), + ]; + + $repo->findExpiredExportGeneration(Argument::that(function ($dateTime) use ($clock) { + // Ensure the repository is called with the current clock time + return $dateTime instanceof \DateTimeImmutable + && $dateTime->getTimestamp() === $clock->now()->getTimestamp(); + }))->willReturn($expiredExports); + + // Expect one RemoveExportGenerationMessage for each expired export + $bus->dispatch(Argument::that(fn (Envelope $envelope) => $envelope->getMessage() instanceof RemoveExportGenerationMessage)) + ->shouldBeCalledTimes(1) + ->will(fn ($args) => $args[0]); + + $cronJob = new RemoveExpiredExportGenerationCronJob( + $clock, + $repo->reveal(), + $bus->reveal() + ); + + $result = $cronJob->run([]); + + $this->assertIsArray($result); + $this->assertEquals(['last-deletion' => $clock->now()->getTimestamp()], $result); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Export/ExportDescriptionHelperTest.php b/src/Bundle/ChillMainBundle/Tests/Export/ExportDescriptionHelperTest.php new file mode 100644 index 000000000..75638dd0f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Export/ExportDescriptionHelperTest.php @@ -0,0 +1,174 @@ +prophesize(Security::class); + $security->getUser()->willReturn($user = new User()); + + $exportConfigNormalizer = $this->prophesize(ExportConfigNormalizer::class); + $exportConfigNormalizer->denormalizeConfig('my_export', Argument::type('array'))->willReturn($options); + + $export = $this->prophesize(ExportInterface::class); + $export->getTitle()->willReturn('Title'); + + $myFilterString = $this->prophesize(FilterInterface::class); + $myFilterString->describeAction(Argument::type('array'), Argument::type(ExportGenerationContext::class))->willReturn($string0 = 'This is a filter description'); + $myFilterArray = $this->prophesize(FilterInterface::class); + $myFilterArray->describeAction(Argument::type('array'), Argument::type(ExportGenerationContext::class))->willReturn([$string1 = 'This is a filter with %argument%', $arg1 = ['%argument%' => 'zero']]); + $myFilterTranslatable = $this->prophesize(FilterInterface::class); + $myFilterTranslatable->describeAction(Argument::type('array'), Argument::type(ExportGenerationContext::class)) + ->willReturn(new class () implements TranslatableInterface { + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return 'translatable'; + } + }); + + $myAggregator = $this->prophesize(AggregatorInterface::class); + $myAggregator->getTitle()->willReturn('Some aggregator'); + + $token = new UsernamePasswordToken($user, 'main', ['ROLE_USER']); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); + + $exportManager = new ExportManager( + new NullLogger(), + $security->reveal(), + $this->prophesize(AuthorizationHelperInterface::class)->reveal(), + $tokenStorage, + ['my_export' => $export->reveal()], + ['my_aggregator' => $myAggregator->reveal()], + [ + 'my_filter_string' => $myFilterString->reveal(), + 'my_filter_array' => $myFilterArray->reveal(), + 'my_filter_translatable' => $myFilterTranslatable->reveal(), + ], + [], + ); + + $exportConfigProcessor = new ExportConfigProcessor($exportManager); + + $translator = $this->prophesize(TranslatorInterface::class); + $translator->trans('Title')->shouldBeCalled()->willReturn('Title'); + $translator->trans($string0)->shouldBeCalled()->willReturn($string0); + $translator->trans($string1, $arg1)->shouldBeCalled()->willReturn($string1); + $translator->trans('Some aggregator')->shouldBeCalled()->willReturn('Some aggregator'); + + $exportDescriptionHelper = new ExportDescriptionHelper( + $exportManager, + $exportConfigNormalizer->reveal(), + $exportConfigProcessor, + $translator->reveal(), + $security->reveal(), + ); + + $actual = $exportDescriptionHelper->describe('my_export', $options); + + self::assertIsArray($actual); + self::assertEquals($actual[0], 'Title'); + self::assertEquals($actual[1], 'This is a filter description'); + self::assertEquals($actual[2], 'This is a filter with %argument%'); + self::assertEquals($actual[3], 'translatable'); + self::assertEquals($actual[4], 'Some aggregator'); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Export/ExportManagerTest.php b/src/Bundle/ChillMainBundle/Tests/Export/ExportManagerTest.php index 6004a6012..d52129af7 100644 --- a/src/Bundle/ChillMainBundle/Tests/Export/ExportManagerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Export/ExportManagerTest.php @@ -348,7 +348,6 @@ final class ExportManagerTest extends KernelTestCase $logger ?? self::getContainer()->get(LoggerInterface::class), $authorizationChecker ?? self::getContainer()->get('security.authorization_checker'), $authorizationHelper ?? self::getContainer()->get('chill.main.security.authorization.helper'), - $tokenStorage, $exports, $aggregators, $filters, diff --git a/src/Bundle/ChillMainBundle/Tests/Export/Messenger/RemoveExportGenerationMessageHandlerTest.php b/src/Bundle/ChillMainBundle/Tests/Export/Messenger/RemoveExportGenerationMessageHandlerTest.php new file mode 100644 index 000000000..70f6f9f75 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Export/Messenger/RemoveExportGenerationMessageHandlerTest.php @@ -0,0 +1,76 @@ + 'bar']); + $storedObject = $exportGeneration->getStoredObject(); + + // 3. Mock ExportGenerationRepository to return the ExportGeneration + $exportGenerationRepository = $this->prophesize(ExportGenerationRepository::class); + $exportGenerationRepository + ->find($exportGeneration->getId()) + ->willReturn($exportGeneration); + + // 4. Mock EntityManagerInterface and set expectations + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->remove($exportGeneration)->shouldBeCalled(); + $entityManager->flush()->shouldBeCalled(); + + // 6. Create message + $message = new RemoveExportGenerationMessage($exportGeneration); + + // 7. Handler instantiation + $handler = new RemoveExportGenerationMessageHandler( + $exportGenerationRepository->reveal(), + $entityManager->reveal(), + new NullLogger(), + $clock + ); + + // Pre-condition: deleteAt not set. + $this->assertNull($storedObject->getDeleteAt()); + + // Act + $handler->__invoke($message); + + // Assert + $this->assertEquals($now, $storedObject->getDeleteAt(), 'deleteAt of stored object was updated'); + } +} diff --git a/src/Bundle/ChillMainBundle/config/services/export.yaml b/src/Bundle/ChillMainBundle/config/services/export.yaml index acbb53f8a..d6a15b6d2 100644 --- a/src/Bundle/ChillMainBundle/config/services/export.yaml +++ b/src/Bundle/ChillMainBundle/config/services/export.yaml @@ -6,8 +6,13 @@ services: Chill\MainBundle\Export\Helper\: resource: '../../Export/Helper' + Chill\MainBundle\Export\Cronjob\: + resource: '../../Export/Cronjob' + Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessageHandler: ~ + Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessageHandler: ~ + Chill\MainBundle\Export\Messenger\OnExportGenerationFails: ~ Chill\MainBundle\Export\ExportFormHelper: ~ @@ -18,6 +23,8 @@ services: Chill\MainBundle\Export\ExportConfigProcessor: ~ + Chill\MainBundle\Export\ExportDescriptionHelper: ~ + chill.main.export_element_validator: class: Chill\MainBundle\Validator\Constraints\Export\ExportElementConstraintValidator tags: diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 593ac70d2..c0394df5d 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -809,6 +809,7 @@ saved_export: Duplicated: Dupliqué Options updated successfully: La configuration de l'export a été mise à jour Share: Partage + Alert auto generated description: La description ci-dessous a été générée automatiquement, comme si l'export était exécutée immédiatement. Veillez à l'adapter pour tenir compte des paramètres qui peuvent être modifiés (utilisateurs courant, dates glissantes, etc.). absence: # single letter for absence