From e8bca6a502cef58394558b3bc3d11abde16efcc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 23 May 2025 15:52:11 +0200 Subject: [PATCH 01/10] Do not sync user which are enabled with UserGroup related to UserJob. Updated SQL queries to include checks for user.enabled status, ensuring proper handling of both enabled and null states. This improves the synchronization logic by aligning it with user activity and account status conditions. --- .../Service/UserGroup/UserGroupRelatedToUserJobSync.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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; From fe31cfd544bc83484d42c906b03d4ed7ab8a501c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 26 May 2025 10:28:55 +0200 Subject: [PATCH 02/10] Refactor export generation to handle saved exports conditionally Simplified the creation of `ExportGeneration` by introducing a `match` expression to handle cases with and without `savedExport`. This improves readability and ensures consistent handling of saved exports while normalizing configuration options. --- .../Controller/ExportController.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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)); From 9adbde0308393eddfbf5deac98e083777cf1bd96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 26 May 2025 10:35:27 +0200 Subject: [PATCH 03/10] Add export configuration comparison and update options logic Introduced a method to compare export generation options with saved exports, enabling detection of configuration differences. Updated template logic to conditionally adjust UI elements based on configuration discrepancies. This enhances flexibility when managing saved export options. --- .../Entity/ExportGeneration.php | 26 ++++++++++- .../views/ExportGeneration/wait.html.twig | 45 ++++++++++--------- 2 files changed, 49 insertions(+), 22 deletions(-) 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/Resources/views/ExportGeneration/wait.html.twig b/src/Bundle/ChillMainBundle/Resources/views/ExportGeneration/wait.html.twig index 89af3a70e..a93edf965 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/ExportGeneration/wait.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/ExportGeneration/wait.html.twig @@ -10,7 +10,10 @@ {{ encore_entry_link_tags('page_download_exports') }} {% endblock %} + {% block title exportGeneration.linkedToSavedExport ? exportGeneration.savedExport.title : 'Download export' %} + {% block content %} +

{{ block('title') }}

- {% if not exportGeneration.isLinkedToSavedExport %} + {% if not exportGeneration.linkedToSavedExport %}
  • {{ 'Save'|trans }}
  • {% else %} -
  • - +
  • + {% endif %} {% endif %} {% endblock content %} From e79d6d670bb0ed3d796e342633f18c22a1296ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 26 May 2025 10:35:36 +0200 Subject: [PATCH 04/10] Fix CS --- .../Filter/AccompanyingCourseFilters/SocialIssueFilter.php | 1 - .../AccompanyingCourseFilters/UserWorkingOnCourseFilter.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/SocialIssueFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/SocialIssueFilter.php index 737f0f3b8..30b00efb2 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/SocialIssueFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/SocialIssueFilter.php @@ -22,7 +22,6 @@ use Chill\PersonBundle\Templating\Entity\SocialIssueRender; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Contracts\Translation\TranslatorInterface; class SocialIssueFilter implements FilterInterface { diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserWorkingOnCourseFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserWorkingOnCourseFilter.php index 3a65d97d5..30aefbce7 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserWorkingOnCourseFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserWorkingOnCourseFilter.php @@ -22,7 +22,6 @@ use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; use Chill\MainBundle\Templating\Entity\UserRender; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Export\Declarations; -use Doctrine\Common\Collections\Collection; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; From e89f5e4713621d8a6a1b25d28fdb2a3a374ca767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 26 May 2025 12:26:48 +0200 Subject: [PATCH 05/10] Add and enforce 'DUPLICATE' permissions for Saved Exports Introduce a new 'DUPLICATE' permission in SavedExportVoter and update related logic in the controller and templates to enforce this rule. Ensure only authorized users can duplicate exports and adjust UI elements accordingly for better permission handling. --- .../Controller/SavedExportController.php | 10 +++++----- .../Resources/views/SavedExport/index.html.twig | 8 +++++++- .../Security/Authorization/SavedExportVoter.php | 7 ++++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php index cb9d89215..22e4a6ba7 100644 --- a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php @@ -120,15 +120,15 @@ final readonly class SavedExportController #[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()) @@ -209,7 +209,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'); } diff --git a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig index 4a9b9490e..40257a996 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig @@ -30,6 +30,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 +67,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 %} 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), }; From 9f32b5ac4828a6294edb594fd528e019ea7892d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 26 May 2025 13:24:42 +0200 Subject: [PATCH 06/10] Refactor BySocialIssueFilter to enhance normalization logic Added ExportDataNormalizerTrait to streamline data normalization and denormalization processes. Updated constructor to inject dependencies for SocialIssueRepository and NormalizerInterface, improving modularity and maintainability. Adjusted form data normalization methods to utilize the new utilities. --- .../Export/Filter/ACPFilters/BySocialIssueFilter.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 From be448c650e9ded59c4e59f0fd45e713089b65c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 26 May 2025 14:16:09 +0200 Subject: [PATCH 07/10] Refactor SavedExport listing to support filtering. Introduced filtering capabilities for SavedExport listings by title and description. Moved index functionality to a new `SavedExportIndexController` and updated the repository with the necessary filter logic. Adjusted the Twig template to render the new filter interface. --- .../Controller/SavedExportController.php | 55 --------- .../Controller/SavedExportIndexController.php | 104 ++++++++++++++++++ .../Repository/SavedExportRepository.php | 24 +++- .../SavedExportRepositoryInterface.php | 12 +- .../views/SavedExport/index.html.twig | 2 + 5 files changed, 140 insertions(+), 57 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Controller/SavedExportIndexController.php diff --git a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php index 22e4a6ba7..ddda96e19 100644 --- a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php @@ -14,13 +14,8 @@ 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\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 +41,9 @@ 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, ) {} #[Route(path: '/{_locale}/exports/saved/{id}/delete', name: 'chill_main_export_saved_delete')] @@ -234,52 +227,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/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/views/SavedExport/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig index 40257a996..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) %}
    @@ -88,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 }}

    From 3a016aa12a2572fd8597127f6bca00e4310d4d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 26 May 2025 16:44:50 +0200 Subject: [PATCH 08/10] Add auto-generated export descriptions and helper service Introduce `ExportDescriptionHelper` to dynamically generate default descriptions for exports based on current settings. Update controllers, templates, and test cases to support and display the new auto-generated descriptions. This also adds a warning in the UI to prompt users to adjust these descriptions as needed. --- .../Controller/SavedExportController.php | 21 ++- .../Export/ExportDescriptionHelper.php | 74 ++++++++ .../Resources/views/SavedExport/new.html.twig | 7 + .../Export/ExportDescriptionHelperTest.php | 174 ++++++++++++++++++ .../Tests/Export/ExportManagerTest.php | 1 - .../config/services/export.yaml | 2 + .../translations/messages.fr.yml | 1 + 7 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Export/ExportDescriptionHelper.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Export/ExportDescriptionHelperTest.php diff --git a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php index ddda96e19..20b9bf1c5 100644 --- a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php @@ -14,6 +14,7 @@ namespace Chill\MainBundle\Controller; use Chill\MainBundle\Entity\ExportGeneration; use Chill\MainBundle\Entity\SavedExport; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Export\ExportDescriptionHelper; use Chill\MainBundle\Export\ExportManager; use Chill\MainBundle\Form\SavedExportType; use Chill\MainBundle\Security\Authorization\ExportGenerationVoter; @@ -44,6 +45,7 @@ final readonly class SavedExportController private Security $security, private TranslatorInterface $translator, private UrlGeneratorInterface $urlGenerator, + private ExportDescriptionHelper $exportDescriptionHelper, ) {} #[Route(path: '/{_locale}/exports/saved/{id}/delete', name: 'chill_main_export_saved_delete')] @@ -107,7 +109,21 @@ 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')] @@ -138,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); @@ -161,6 +177,7 @@ final readonly class SavedExportController '@ChillMain/SavedExport/new.html.twig', [ 'form' => $form->createView(), + 'showWarningAutoGeneratedDescription' => $showWarningAutoGeneratedDescription, ], ), ); 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/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/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/config/services/export.yaml b/src/Bundle/ChillMainBundle/config/services/export.yaml index acbb53f8a..3027532e1 100644 --- a/src/Bundle/ChillMainBundle/config/services/export.yaml +++ b/src/Bundle/ChillMainBundle/config/services/export.yaml @@ -18,6 +18,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 a2d718143..1141f50fc 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -807,6 +807,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 From c40e7904254db1505500141d9ba2c68c4d71361f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 26 May 2025 17:46:46 +0200 Subject: [PATCH 09/10] Add handling and cleanup for expired export generations Implemented a new cron job to identify and process expired export generations, dispatching messages for their removal. Added corresponding message handler, tests, and configuration updates to handle and orchestrate the deletion workflow. --- config/packages/messenger.yaml | 1 + .../RemoveExpiredExportGenerationCronJob.php | 52 ++++++++ .../RemoveExportGenerationMessage.php | 24 ++++ .../RemoveExportGenerationMessageHandler.php | 49 ++++++++ .../Repository/ExportGenerationRepository.php | 9 ++ ...moveExpiredExportGenerationCronJobTest.php | 118 ++++++++++++++++++ ...moveExportGenerationMessageHandlerTest.php | 76 +++++++++++ .../config/services/export.yaml | 5 + 8 files changed, 334 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/Export/Cronjob/RemoveExpiredExportGenerationCronJob.php create mode 100644 src/Bundle/ChillMainBundle/Export/Messenger/RemoveExportGenerationMessage.php create mode 100644 src/Bundle/ChillMainBundle/Export/Messenger/RemoveExportGenerationMessageHandler.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Export/Cronjob/RemoveExpiredExportGenerationCronJobTest.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Export/Messenger/RemoveExportGenerationMessageHandlerTest.php 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/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/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/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/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 3027532e1..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: ~ From 96e95dd8f1d706a167bcc037eda7e19a7a7259db Mon Sep 17 00:00:00 2001 From: Julie Lenaerts Date: Tue, 27 May 2025 16:19:57 +0200 Subject: [PATCH 10/10] Assure checkbox current user is checked in saved export --- .../Resources/public/vuejs/PickEntity/PickEntity.vue | 1 + 1 file changed, 1 insertion(+) 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 @@