Merge remote-tracking branch 'origin/339-partage-d'export-enregistré' into testing-202505

This commit is contained in:
Julien Fastré 2025-05-30 15:00:37 +02:00
commit 43e5bc8337
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
25 changed files with 838 additions and 102 deletions

View File

@ -63,6 +63,7 @@ framework:
'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async 'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async
'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async 'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async
'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority 'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority
'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async
# end of routes added by chill-bundles recipes # end of routes added by chill-bundles recipes
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async

View File

@ -12,17 +12,21 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Export\Filter\ACPFilters; namespace Chill\ActivityBundle\Export\Filter\ACPFilters;
use Chill\ActivityBundle\Export\Declarations; use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\ExportDataNormalizerTrait;
use Chill\MainBundle\Export\ExportGenerationContext; use Chill\MainBundle\Export\ExportGenerationContext;
use Chill\MainBundle\Export\FilterInterface; use Chill\MainBundle\Export\FilterInterface;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue; use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Chill\PersonBundle\Form\Type\PickSocialIssueType; use Chill\PersonBundle\Form\Type\PickSocialIssueType;
use Chill\PersonBundle\Repository\SocialWork\SocialIssueRepository;
use Chill\PersonBundle\Templating\Entity\SocialIssueRender; use Chill\PersonBundle\Templating\Entity\SocialIssueRender;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
class BySocialIssueFilter implements FilterInterface 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 public function addRole(): ?string
{ {
@ -63,12 +67,12 @@ class BySocialIssueFilter implements FilterInterface
public function normalizeFormData(array $formData): array 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 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 public function getFormDefaultData(): array

View File

@ -316,13 +316,14 @@ class ExportController extends AbstractController
$savedExport, $savedExport,
); );
$this->entityManager->persist( $deleteAt = $this->clock->now()->add(new \DateInterval('P6M'));
$exportGeneration = new ExportGeneration( $options = $this->exportConfigNormalizer->normalizeConfig($alias, $dataToNormalize);
$alias, $exportGeneration = match (null === $savedExport) {
$this->exportConfigNormalizer->normalizeConfig($alias, $dataToNormalize), true => new ExportGeneration($alias, $options, $deleteAt),
$this->clock->now()->add(new \DateInterval('P6M')), false => ExportGeneration::fromSavedExport($savedExport, $deleteAt, $options),
), };
);
$this->entityManager->persist($exportGeneration);
$this->entityManager->flush(); $this->entityManager->flush();
$this->messageBus->dispatch(new ExportRequestGenerationMessage($exportGeneration, $user)); $this->messageBus->dispatch(new ExportRequestGenerationMessage($exportGeneration, $user));

View File

@ -14,13 +14,9 @@ namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration; use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport; use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportInterface; use Chill\MainBundle\Export\ExportDescriptionHelper;
use Chill\MainBundle\Export\ExportManager; use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Form\SavedExportType; 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\ExportGenerationVoter;
use Chill\MainBundle\Security\Authorization\SavedExportVoter; use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -46,11 +42,10 @@ final readonly class SavedExportController
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private ExportManager $exportManager, private ExportManager $exportManager,
private FormFactoryInterface $formFactory, private FormFactoryInterface $formFactory,
private SavedExportRepositoryInterface $savedExportRepository,
private Security $security, private Security $security,
private TranslatorInterface $translator, private TranslatorInterface $translator,
private UrlGeneratorInterface $urlGenerator, private UrlGeneratorInterface $urlGenerator,
private ExportGenerationRepository $exportGenerationRepository, private ExportDescriptionHelper $exportDescriptionHelper,
) {} ) {}
#[Route(path: '/{_locale}/exports/saved/{id}/delete', name: 'chill_main_export_saved_delete')] #[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 $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')] #[Route(path: '/exports/saved/duplicate-from-saved-export/{id}/new', name: 'chill_main_export_saved_duplicate')]
public function duplicate(SavedExport $previousSavedExport, Request $request): Response 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(); $user = $this->security->getUser();
if (!$user instanceof User) { if (!$user instanceof User) {
throw new AccessDeniedHttpException('only regular user can create a saved export'); 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 = new SavedExport();
$savedExport $savedExport
->setExportAlias($previousSavedExport->getExportAlias()) ->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 = $this->formFactory->create(SavedExportType::class, $savedExport);
$form->handleRequest($request); $form->handleRequest($request);
@ -168,6 +177,7 @@ final readonly class SavedExportController
'@ChillMain/SavedExport/new.html.twig', '@ChillMain/SavedExport/new.html.twig',
[ [
'form' => $form->createView(), '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')] #[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 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'); 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()]), $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<string, array{saved: SavedExport, export: ExportInterface}> $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,
],
),
);
}
} }

View File

@ -0,0 +1,104 @@
<?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\MainBundle\Controller;
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\Repository\ExportGenerationRepository;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class SavedExportIndexController
{
public function __construct(
private \Twig\Environment $templating,
private ExportManager $exportManager,
private SavedExportRepositoryInterface $savedExportRepository,
private Security $security,
private TranslatorInterface $translator,
private ExportGenerationRepository $exportGenerationRepository,
private FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
) {}
#[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));
}
$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<string, array{saved: SavedExport, export: ExportInterface}> $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();
}
}

View File

@ -54,6 +54,13 @@ class ExportGeneration implements TrackCreationInterface
private array $options = [], private array $options = [],
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deleteAt = null, 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\ManyToOne(targetEntity: SavedExport::class)]
#[ORM\JoinColumn(nullable: true)] #[ORM\JoinColumn(nullable: true)]
private ?SavedExport $savedExport = null, private ?SavedExport $savedExport = null,
@ -118,9 +125,24 @@ class ExportGeneration implements TrackCreationInterface
return null !== $this->savedExport; 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()); $generation->getStoredObject()->setTitle($savedExport->getTitle());
return $generation; return $generation;

View File

@ -0,0 +1,52 @@
<?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\MainBundle\Export\Cronjob;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final readonly class RemoveExpiredExportGenerationCronJob implements CronJobInterface
{
public const KEY = 'remove-expired-export-generation';
public function __construct(private ClockInterface $clock, private ExportGenerationRepository $exportGenerationRepository, private MessageBusInterface $messageBus) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
if (null === $cronJobExecution) {
return true;
}
return $cronJobExecution->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()];
}
}

View File

@ -0,0 +1,74 @@
<?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\MainBundle\Export;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Give an explanation of an export.
*/
final readonly class ExportDescriptionHelper
{
public function __construct(
private ExportManager $exportManager,
private ExportConfigNormalizer $exportConfigNormalizer,
private ExportConfigProcessor $exportConfigProcessor,
private TranslatorInterface $translator,
private Security $security,
) {}
/**
* @return list<string>
*/
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] ?? []);
}
}

View File

@ -0,0 +1,24 @@
<?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\MainBundle\Export\Messenger;
use Chill\MainBundle\Entity\ExportGeneration;
final readonly class RemoveExportGenerationMessage
{
public string $exportGenerationId;
public function __construct(ExportGeneration $exportGeneration)
{
$this->exportGenerationId = $exportGeneration->getId()->toString();
}
}

View File

@ -0,0 +1,49 @@
<?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\MainBundle\Export\Messenger;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
#[AsMessageHandler]
class RemoveExportGenerationMessageHandler implements MessageHandlerInterface
{
private const LOG_PREFIX = '[RemoveExportGenerationMessageHandler] ';
public function __construct(
private ExportGenerationRepository $exportGenerationRepository,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private ClockInterface $clock,
) {}
public function __invoke(RemoveExportGenerationMessage $message): void
{
$exportGeneration = $this->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();
}
}

View File

@ -73,4 +73,13 @@ class ExportGenerationRepository extends ServiceEntityRepository implements Asso
->getQuery() ->getQuery()
->getResult(); ->getResult();
} }
public function findExpiredExportGeneration(\DateTimeImmutable $atDate): iterable
{
return $this->createQueryBuilder('e')
->where('e.deleteAt < :atDate')
->setParameter('atDate', $atDate)
->getQuery()
->toIterable();
}
} }

View File

@ -18,6 +18,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\String\UnicodeString;
/** /**
* @implements ObjectRepository<SavedExport> * @implements ObjectRepository<SavedExport>
@ -60,7 +61,7 @@ class SavedExportRepository implements SavedExportRepositoryInterface
return $this->prepareResult($qb, $orderBy, $limit, $offset); 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'); $qb = $this->repository->createQueryBuilder('se');
@ -76,6 +77,27 @@ class SavedExportRepository implements SavedExportRepositoryInterface
) )
->setParameter('user', $user); ->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); return $this->prepareResult($qb, $orderBy, $limit, $offset);
} }

View File

@ -20,6 +20,9 @@ use Doctrine\Persistence\ObjectRepository;
*/ */
interface SavedExportRepositoryInterface extends ObjectRepository interface SavedExportRepositoryInterface extends ObjectRepository
{ {
public const FILTER_TITLE = 0x01;
public const FILTER_DESCRIPTION = 0x10;
public function find($id): ?SavedExport; 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 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<int, mixed> $filters filters where keys are one of the constant starting with FILTER_
*
* @return list<SavedExport>
*/
public function findSharedWithUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null, array $filters = []): array;
public function findOneBy(array $criteria): ?SavedExport; public function findOneBy(array $criteria): ?SavedExport;

View File

@ -13,6 +13,7 @@
<li v-if="isCurrentUserPicker" class="btn btn-sm btn-misc"> <li v-if="isCurrentUserPicker" class="btn btn-sm btn-misc">
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input <input
:checked="picked.indexOf('me') >= 0 ? true : null"
ref="itsMeCheckbox" ref="itsMeCheckbox"
:type="multiple ? 'checkbox' : 'radio'" :type="multiple ? 'checkbox' : 'radio'"
@change="selectItsMe" @change="selectItsMe"

View File

@ -10,7 +10,10 @@
{{ encore_entry_link_tags('page_download_exports') }} {{ encore_entry_link_tags('page_download_exports') }}
{% endblock %} {% endblock %}
{% block title exportGeneration.linkedToSavedExport ? exportGeneration.savedExport.title : 'Download export' %}
{% block content %} {% block content %}
<h1>{{ block('title') }}</h1>
<div id="app" <div id="app"
data-export-generation-id="{{ exportGeneration.id | escape('html_attr') }}" data-export-generation-id="{{ exportGeneration.id | escape('html_attr') }}"
data-export-generation-date="{{ exportGeneration.createdAt.format('Ymd-His') }}" data-export-generation-date="{{ exportGeneration.createdAt.format('Ymd-His') }}"
@ -23,34 +26,36 @@
{{ 'export.generation.Come back later'|trans|chill_return_path_label }} {{ 'export.generation.Come back later'|trans|chill_return_path_label }}
</a> </a>
</li> </li>
{% if not exportGeneration.isLinkedToSavedExport %} {% if not exportGeneration.linkedToSavedExport %}
<li> <li>
<a href="{{ chill_path_add_return_path('chill_main_export_saved_create_from_export_generation', {'id': exportGeneration.id}) }}" class="btn btn-save"> <a href="{{ chill_path_add_return_path('chill_main_export_saved_create_from_export_generation', {'id': exportGeneration.id}) }}" class="btn btn-save">
{{ 'Save'|trans }} {{ 'Save'|trans }}
</a> </a>
</li> </li>
{% else %} {% else %}
<li> {% if exportGeneration.configurationDifferentFromSavedExport %}
<div class="dropdown"> <li>
<button class="btn btn-save dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">{{ 'Save'|trans }}</button> <div class="dropdown">
<ul class="dropdown-menu dropdown-menu-end"> <button class="btn btn-save dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">{{ 'Save'|trans }}</button>
<li class="dropdown-item"> <ul class="dropdown-menu dropdown-menu-end">
<a href="{{ chill_path_add_return_path('chill_main_export_saved_create_from_export_generation', {'id': exportGeneration.id, 'title': exportGeneration.savedExport.title ~ ' (' ~ 'saved_export.Duplicated'|trans ~ ' ' ~ null|format_datetime('short', 'medium') ~ ')'}) }}" class="btn">
<i class="bi bi-copy"></i> {{ 'saved_export.Save to new saved export'|trans }}
</a>
</li>
{% if is_granted('CHILL_MAIN_EXPORT_SAVED_EDIT', exportGeneration.savedExport) %}
<li class="dropdown-item"> <li class="dropdown-item">
<form method="POST" action="{{ path('chill_main_export_saved_options_edit', {'savedExport': exportGeneration.savedExport.id, 'exportGeneration': exportGeneration.id }) }}"> <a href="{{ chill_path_add_return_path('chill_main_export_saved_create_from_export_generation', {'id': exportGeneration.id, 'title': exportGeneration.savedExport.title ~ ' (' ~ 'saved_export.Duplicated'|trans ~ ' ' ~ null|format_datetime('short', 'medium') ~ ')'}) }}" class="btn">
<button type="submit" class="btn"> <i class="bi bi-copy"></i> {{ 'saved_export.Save to new saved export'|trans }}
<i class="bi bi-floppy"></i> {{ 'saved_export.Update current saved export'|trans }} </a>
</button>
</form>
</li> </li>
{% endif %} {% if is_granted('CHILL_MAIN_EXPORT_SAVED_EDIT', exportGeneration.savedExport) %}
</ul> <li class="dropdown-item">
</div> <form method="POST" action="{{ path('chill_main_export_saved_options_edit', {'savedExport': exportGeneration.savedExport.id, 'exportGeneration': exportGeneration.id }) }}">
</li> <button type="submit" class="btn">
<i class="bi bi-floppy"></i> {{ 'saved_export.Update current saved export'|trans }}
</button>
</form>
</li>
{% endif %}
</ul>
</div>
</li>
{% endif %}
{% endif %} {% endif %}
</ul> </ul>
{% endblock content %} {% endblock content %}

View File

@ -18,6 +18,7 @@
{% block title %}{{ 'saved_export.Saved exports'|trans }}{% endblock %} {% block title %}{{ 'saved_export.Saved exports'|trans }}{% endblock %}
{% macro render_export_card(saved, export, export_alias, generations) %} {% macro render_export_card(saved, export, export_alias, generations) %}
<div class="col"> <div class="col">
<div class="card h-100"> <div class="card h-100">
@ -30,6 +31,10 @@
<p class="card-text tags"> <p class="card-text tags">
{% if app.user is same as saved.user %}<span class="badge bg-primary">{{ 'saved_export.Owner'|trans }}</span>{% endif %} {% if app.user is same as saved.user %}<span class="badge bg-primary">{{ 'saved_export.Owner'|trans }}</span>{% endif %}
</p> </p>
{% else %}
<p class="card-text tags">
Partagé par <span class="badge-user">{{ saved.user|chill_entity_render_box }}</span>
</p>
{% endif %} {% endif %}
<p class="card-text my-3">{{ saved.description|chill_markdown_to_html }}</p> <p class="card-text my-3">{{ saved.description|chill_markdown_to_html }}</p>
</div> </div>
@ -63,7 +68,9 @@
{% endif %} {% endif %}
{# reminder: the controller already checked that the user can generate saved exports #} {# reminder: the controller already checked that the user can generate saved exports #}
<li><a href="{{ chill_path_add_return_path('chill_main_export_new', {'alias': saved.exportAlias,'from_saved': saved.id }) }}" class="dropdown-item"><i class="fa fa-pencil"></i> {{ 'saved_export.update_filters_aggregators_and_execute'|trans }}</a></li> <li><a href="{{ chill_path_add_return_path('chill_main_export_new', {'alias': saved.exportAlias,'from_saved': saved.id }) }}" class="dropdown-item"><i class="fa fa-pencil"></i> {{ 'saved_export.update_filters_aggregators_and_execute'|trans }}</a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_duplicate', {'id': saved.id}) }}" class="dropdown-item"><i class="fa fa-copy"></i> {{ 'saved_export.Duplicate'|trans }}</a></li> {% if is_granted('CHILL_MAIN_EXPORT_SAVED_DUPLICATE', saved) %}
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_duplicate', {'id': saved.id}) }}" class="dropdown-item"><i class="fa fa-copy"></i> {{ 'saved_export.Duplicate'|trans }}</a></li>
{% endif %}
{% if is_granted('CHILL_MAIN_EXPORT_SAVED_DELETE', saved) %} {% if is_granted('CHILL_MAIN_EXPORT_SAVED_DELETE', saved) %}
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': saved.id }) }}" class="dropdown-item"><i class="fa fa-trash"></i> {{ 'Delete'|trans }}</a></li> <li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': saved.id }) }}" class="dropdown-item"><i class="fa fa-trash"></i> {{ 'Delete'|trans }}</a></li>
{% endif %} {% endif %}
@ -82,6 +89,7 @@
{{ include('@ChillMain/Export/_navbar.html.twig', {'current' : 'my'}) }} {{ include('@ChillMain/Export/_navbar.html.twig', {'current' : 'my'}) }}
<div class="container mt-4"> <div class="container mt-4">
{{ filter|chill_render_filter_order_helper }}
{% if total == 0 %} {% if total == 0 %}
<p class="chill-no-data-statement" >{{ 'saved_export.Any saved export'|trans }}</p> <p class="chill-no-data-statement" >{{ 'saved_export.Any saved export'|trans }}</p>

View File

@ -18,6 +18,13 @@
{{ form_start(form) }} {{ form_start(form) }}
{{ form_row(form.title) }} {{ form_row(form.title) }}
{% if showWarningAutoGeneratedDescription|default(false) %}
<div class="alert alert-info" role="alert">
{{ 'saved_export.Alert auto generated description'|trans }}
</div>
{% endif %}
{{ form_row(form.description) }} {{ form_row(form.description) }}
{% if form.share is defined %} {% if form.share is defined %}

View File

@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportManager; use Chill\MainBundle\Export\ExportManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class SavedExportVoter extends 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 GENERATE = 'CHILL_MAIN_EXPORT_SAVED_GENERATE';
final public const DUPLICATE = 'CHILL_MAIN_EXPORT_SAVED_DUPLICATE';
final public const SHARE = 'CHILL_MAIN_EXPORT_SAVED_SHARE'; final public const SHARE = 'CHILL_MAIN_EXPORT_SAVED_SHARE';
private const ALL = [ private const ALL = [
@ -32,9 +35,10 @@ final class SavedExportVoter extends Voter
self::EDIT, self::EDIT,
self::GENERATE, self::GENERATE,
self::SHARE, 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 protected function supports($attribute, $subject): bool
{ {
@ -52,6 +56,7 @@ final class SavedExportVoter extends Voter
return match ($attribute) { return match ($attribute) {
self::DELETE, self::EDIT, self::SHARE => $subject->getUser() === $token->getUser(), 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), self::GENERATE => $this->canUserGenerate($user, $subject),
default => throw new \UnexpectedValueException('attribute not supported: '.$attribute), default => throw new \UnexpectedValueException('attribute not supported: '.$attribute),
}; };

View File

@ -87,8 +87,8 @@ final readonly class UserGroupRelatedToUserJobSync implements UserGroupRelatedTo
AND chill_main_user_group_user.user_id = u.id AND chill_main_user_group_user.user_id = u.id
AND jh.user_id = u.id AND tsrange(jh.startdate, jh.enddate) @> localtimestamp 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 -- only when the user's jobid is different than the user_group id
AND ug.userjob_id IS NOT NULL -- or where the user.enabled is null
AND jh.job_id <> ug.userjob_id AND ((ug.userjob_id IS NOT NULL AND jh.job_id <> ug.userjob_id) OR u.enabled IS NULL)
SQL; SQL;
$result = $connection->executeQuery($sql); $result = $connection->executeQuery($sql);
@ -103,7 +103,8 @@ final readonly class UserGroupRelatedToUserJobSync implements UserGroupRelatedTo
SELECT cmug.id, jh.user_id SELECT cmug.id, jh.user_id
FROM chill_main_user_group cmug 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 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 ON CONFLICT DO NOTHING
SQL; SQL;

View File

@ -0,0 +1,118 @@
<?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\MainBundle\Tests\Export\Cronjob;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Export\Cronjob\RemoveExpiredExportGenerationCronJob;
use Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Argument;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Chill\MainBundle\Repository\ExportGenerationRepository;
/**
* @internal
*
* @coversNothing
*/
class RemoveExpiredExportGenerationCronJobTest extends TestCase
{
use ProphecyTrait;
public function testCanRunReturnsTrueWhenLastExecutionIsNull()
{
$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()
);
$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);
}
}

View File

@ -0,0 +1,174 @@
<?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\MainBundle\Tests\Export;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Export\ExportConfigNormalizer;
use Chill\MainBundle\Export\ExportConfigProcessor;
use Chill\MainBundle\Export\ExportDescriptionHelper;
use Chill\MainBundle\Export\ExportGenerationContext;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*
* @coversNothing
*/
class ExportDescriptionHelperTest extends TestCase
{
use ProphecyTrait;
private const JSON_HAPPY_SCENARIO = <<<'JSON'
{
"export": {
"form": [],
"version": 1
},
"centers": {
"centers": [
1
],
"regroupments": []
},
"filters": {
"my_filter_string": {
"form": {
"accepted_socialissues": [
2,
3
]
},
"enabled": true,
"version": 1
},
"my_filter_array": {
"form": {
"misc": true
},
"enabled": true,
"version": 1
},
"my_filter_translatable": {
"form": {
"misc": true
},
"enabled": true,
"version": 1
}
},
"formatter": {
"form": {
"format": "xlsx",
"activity_user_aggregator": {
"order": 1
}
},
"version": 1
},
"aggregators": {
"my_aggregator": {
"form": {"key": 1},
"enabled": true,
"version": 1
}
},
"pick_formatter": "spreadsheet"
}
JSON;
public function testDescribeHappyScenario(): void
{
$options = json_decode(self::JSON_HAPPY_SCENARIO, true);
$security = $this->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');
}
}

View File

@ -348,7 +348,6 @@ final class ExportManagerTest extends KernelTestCase
$logger ?? self::getContainer()->get(LoggerInterface::class), $logger ?? self::getContainer()->get(LoggerInterface::class),
$authorizationChecker ?? self::getContainer()->get('security.authorization_checker'), $authorizationChecker ?? self::getContainer()->get('security.authorization_checker'),
$authorizationHelper ?? self::getContainer()->get('chill.main.security.authorization.helper'), $authorizationHelper ?? self::getContainer()->get('chill.main.security.authorization.helper'),
$tokenStorage,
$exports, $exports,
$aggregators, $aggregators,
$filters, $filters,

View File

@ -0,0 +1,76 @@
<?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\MainBundle\Tests\Export\Messenger;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage;
use Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessageHandler;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
/**
* @internal
*
* @coversNothing
*/
class RemoveExportGenerationMessageHandlerTest extends TestCase
{
use ProphecyTrait;
public function testInvokeUpdatesDeleteAtAndRemovesAndFlushes()
{
// Arrange
// 1. Create a MockClock at a fixed point in time
$now = new \DateTimeImmutable('2024-06-01T12:00:00');
$clock = new MockClock($now);
// 2. Create an ExportGeneration entity with a stored object
$exportGeneration = new ExportGeneration('test-alias', ['foo' => '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');
}
}

View File

@ -6,8 +6,13 @@ services:
Chill\MainBundle\Export\Helper\: Chill\MainBundle\Export\Helper\:
resource: '../../Export/Helper' resource: '../../Export/Helper'
Chill\MainBundle\Export\Cronjob\:
resource: '../../Export/Cronjob'
Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessageHandler: ~ Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessageHandler: ~
Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessageHandler: ~
Chill\MainBundle\Export\Messenger\OnExportGenerationFails: ~ Chill\MainBundle\Export\Messenger\OnExportGenerationFails: ~
Chill\MainBundle\Export\ExportFormHelper: ~ Chill\MainBundle\Export\ExportFormHelper: ~
@ -18,6 +23,8 @@ services:
Chill\MainBundle\Export\ExportConfigProcessor: ~ Chill\MainBundle\Export\ExportConfigProcessor: ~
Chill\MainBundle\Export\ExportDescriptionHelper: ~
chill.main.export_element_validator: chill.main.export_element_validator:
class: Chill\MainBundle\Validator\Constraints\Export\ExportElementConstraintValidator class: Chill\MainBundle\Validator\Constraints\Export\ExportElementConstraintValidator
tags: tags:

View File

@ -809,6 +809,7 @@ saved_export:
Duplicated: Dupliqué Duplicated: Dupliqué
Options updated successfully: La configuration de l'export a été mise à jour Options updated successfully: La configuration de l'export a été mise à jour
Share: Partage 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: absence:
# single letter for absence # single letter for absence