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.
This commit is contained in:
Julien Fastré 2025-05-26 16:44:50 +02:00
parent be448c650e
commit 3a016aa12a
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
7 changed files with 277 additions and 3 deletions

View File

@ -14,6 +14,7 @@ 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\ExportDescriptionHelper;
use Chill\MainBundle\Export\ExportManager; use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Form\SavedExportType; use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Security\Authorization\ExportGenerationVoter; use Chill\MainBundle\Security\Authorization\ExportGenerationVoter;
@ -44,6 +45,7 @@ final readonly class SavedExportController
private Security $security, private Security $security,
private TranslatorInterface $translator, private TranslatorInterface $translator,
private UrlGeneratorInterface $urlGenerator, private UrlGeneratorInterface $urlGenerator,
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')]
@ -107,7 +109,21 @@ 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')]
@ -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 = $this->formFactory->create(SavedExportType::class, $savedExport);
$form->handleRequest($request); $form->handleRequest($request);
@ -161,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,
], ],
), ),
); );

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

@ -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

@ -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

@ -18,6 +18,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

@ -807,6 +807,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