Partage d'export enregistré et génération asynchrone des exports

This commit is contained in:
2025-07-08 13:53:25 +00:00
parent c4cc0baa8e
commit 8bc16dadb0
447 changed files with 14134 additions and 3854 deletions

View File

@@ -14,7 +14,6 @@ namespace Chill\MainBundle;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ExportsCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass;
@@ -66,7 +65,6 @@ class ChillMainBundle extends Bundle
$container->addCompilerPass(new SearchableServicesCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new TimelineCompilerClass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new ExportsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new WidgetsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new NotificationCounterCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new MenuCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);

View File

@@ -11,35 +11,39 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\DirectExportInterface;
use Chill\MainBundle\Export\ExportConfigNormalizer;
use Chill\MainBundle\Export\ExportConfigProcessor;
use Chill\MainBundle\Export\ExportFormHelper;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FormatterType;
use Chill\MainBundle\Form\Type\Export\PickCenterType;
use Chill\MainBundle\Redis\ChillRedis;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Repository\SavedExportOrExportGenerationRepository;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class ExportController
@@ -50,117 +54,23 @@ class ExportController extends AbstractController
private readonly bool $filterStatsByCenters;
public function __construct(
private readonly ChillRedis $redis,
private readonly ExportManager $exportManager,
private readonly FormFactoryInterface $formFactory,
private readonly LoggerInterface $logger,
private readonly SessionInterface $session,
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $entityManager,
private readonly ExportFormHelper $exportFormHelper,
private readonly SavedExportRepositoryInterface $savedExportRepository,
private readonly Security $security,
ParameterBagInterface $parameterBag,
private readonly MessageBusInterface $messageBus,
private readonly ClockInterface $clock,
private readonly ExportConfigNormalizer $exportConfigNormalizer,
private readonly SavedExportOrExportGenerationRepository $savedExportOrExportGenerationRepository,
private readonly ExportConfigProcessor $exportConfigProcessor,
) {
$this->filterStatsByCenters = $parameterBag->get('chill_main')['acl']['filter_stats_by_center'];
}
#[Route(path: '/{_locale}/exports/download/{alias}', name: 'chill_main_export_download', methods: ['GET'])]
public function downloadResultAction(Request $request, mixed $alias)
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$export = $exportManager->getExport($alias);
$key = $request->query->get('key', null);
$savedExport = $this->getSavedExportFromRequest($request);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key, $savedExport);
$formatterAlias = $exportManager->getFormatterAlias($dataExport['export']);
if (null !== $formatterAlias) {
$formater = $exportManager->getFormatter($formatterAlias);
} else {
$formater = null;
}
$viewVariables = [
'alias' => $alias,
'export' => $export,
'export_group' => $this->getExportGroup($export),
'saved_export' => $savedExport,
];
if ($formater instanceof \Chill\MainBundle\Export\Formatter\CSVListFormatter) {
// due to a bug in php, we add the mime type in the download view
$viewVariables['mime_type'] = 'text/csv';
}
return $this->render('@ChillMain/Export/download.html.twig', $viewVariables);
}
/**
* Generate a report.
*
* This action must work with GET queries.
*
* @param string $alias
*/
#[Route(path: '/{_locale}/exports/generate/{alias}', name: 'chill_main_export_generate', methods: ['GET'])]
public function generateAction(Request $request, $alias): Response
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$key = $request->query->get('key', null);
$savedExport = $this->getSavedExportFromRequest($request);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key, $savedExport);
return $exportManager->generate(
$alias,
$dataCenters['centers'],
$dataExport['export'],
null !== $dataFormatter ? $dataFormatter['formatter'] : []
);
}
/**
* @throws \RedisException
*/
#[Route(path: '/{_locale}/exports/generate-from-saved/{id}', name: 'chill_main_export_generate_from_saved')]
public function generateFromSavedExport(SavedExport $savedExport): RedirectResponse
{
$this->denyAccessUnlessGranted(SavedExportVoter::GENERATE, $savedExport);
$key = md5(uniqid((string) random_int(0, mt_getrandmax()), false));
$this->redis->setEx($key, 3600, \serialize($savedExport->getOptions()));
return $this->redirectToRoute(
'chill_main_export_download',
[
'alias' => $savedExport->getExportAlias(),
'key' => $key, 'prevent_save' => true,
'returnPath' => $this->generateUrl('chill_main_export_saved_list_my'),
]
);
}
/**
* Render the list of available exports.
*/
#[Route(path: '/{_locale}/exports/', name: 'chill_main_export_index')]
public function indexAction(): Response
{
$exportManager = $this->exportManager;
$exports = $exportManager->getExportsGrouped(true);
return $this->render('@ChillMain/Export/layout.html.twig', [
'grouped_exports' => $exports,
]);
}
/**
* handle the step to build a query for an export.
*
@@ -197,64 +107,6 @@ class ExportController extends AbstractController
};
}
#[Route(path: '/{_locale}/export/saved/update-from-key/{id}/{key}', name: 'chill_main_export_saved_edit_options_from_key')]
public function editSavedExportOptionsFromKey(SavedExport $savedExport, string $key): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException();
}
$data = $this->rebuildRawData($key);
$savedExport
->setOptions($data);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_export_saved_edit', ['id' => $savedExport->getId()]);
}
#[Route(path: '/{_locale}/export/save-from-key/{alias}/{key}', name: 'chill_main_export_save_from_key')]
public function saveFromKey(string $alias, string $key, Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException();
}
$data = $this->rebuildRawData($key);
$savedExport = new SavedExport();
$savedExport
->setOptions($data)
->setExportAlias($alias)
->setUser($user);
$form = $this->createForm(SavedExportType::class, $savedExport);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($savedExport);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_export_index');
}
return $this->render(
'@ChillMain/SavedExport/new.html.twig',
[
'form' => $form->createView(),
'saved_export' => $savedExport,
]
);
}
/**
* create a form to show on different steps.
*
@@ -262,19 +114,26 @@ class ExportController extends AbstractController
*/
protected function createCreateFormExport(string $alias, string $step, array $data, ?SavedExport $savedExport): FormInterface
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$isGenerate = str_starts_with($step, 'generate_');
$canEditFull = $this->security->isGranted(ChillExportVoter::COMPOSE_EXPORT);
if (!$canEditFull && null === $savedExport) {
throw new AccessDeniedHttpException('The user is not allowed to edit all filter, it should edit only SavedExport');
}
$options = match ($step) {
'export', 'generate_export' => [
'export_alias' => $alias,
'picked_centers' => $exportManager->getPickedCenters($data['centers'] ?? []),
'picked_centers' => $this->filterStatsByCenters ? $this->exportFormHelper->getPickedCenters($data) : [],
'can_edit_full' => $canEditFull,
'allowed_filters' => $canEditFull ? null : $this->exportConfigProcessor->retrieveUsedFilters($savedExport->getOptions()['filters']),
'allowed_aggregators' => $canEditFull ? null : $this->exportConfigProcessor->retrieveUsedAggregators($savedExport->getOptions()['aggregators']),
],
'formatter', 'generate_formatter' => [
'export_alias' => $alias,
'formatter_alias' => $exportManager->getFormatterAlias($data['export']),
'aggregator_aliases' => $exportManager->getUsedAggregatorsAliases($data['export']),
'aggregator_aliases' => $exportManager->getUsedAggregatorsAliases($data['export']['aggregators']),
],
default => [
'export_alias' => $alias,
@@ -283,14 +142,14 @@ class ExportController extends AbstractController
$defaultFormData = match ($savedExport) {
null => $this->exportFormHelper->getDefaultData($step, $exportManager->getExport($alias), $options),
default => $this->exportFormHelper->savedExportDataToFormData($savedExport, $step, $options),
default => $this->exportFormHelper->savedExportDataToFormData($savedExport, $step),
};
$builder = $this->formFactory
->createNamedBuilder(
'',
FormType::class,
$defaultFormData,
'centers' === $step ? ['centers' => $defaultFormData] : $defaultFormData,
[
'method' => $isGenerate ? Request::METHOD_GET : Request::METHOD_POST,
'csrf_protection' => !$isGenerate,
@@ -429,13 +288,14 @@ class ExportController extends AbstractController
* and redirect to the `generate` action.
*
* The data from previous steps is removed from session.
*
* @param string $alias
*
* @return RedirectResponse
*/
private function forwardToGenerate(Request $request, DirectExportInterface|ExportInterface $export, $alias, ?SavedExport $savedExport)
private function forwardToGenerate(Request $request, DirectExportInterface|ExportInterface $export, $alias, ?SavedExport $savedExport): Response
{
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('only regular users can generate export');
}
$dataCenters = $this->session->get('centers_step_raw', null);
$dataFormatter = $this->session->get('formatter_step_raw', null);
$dataExport = $this->session->get('export_step_raw', null);
@@ -448,60 +308,82 @@ class ExportController extends AbstractController
]);
}
$parameters = [
'formatter' => $dataFormatter ?? [],
'export' => $dataExport ?? [],
'centers' => $dataCenters ?? [],
'alias' => $alias,
];
unset($parameters['_token']);
$key = md5(uniqid((string) random_int(0, mt_getrandmax()), false));
$dataToNormalize = $this->buildExportDataForNormalization(
$alias,
$dataCenters,
$dataExport,
$dataFormatter,
$savedExport,
);
$this->redis->setEx($key, 3600, \serialize($parameters));
$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));
// remove data from session
$this->session->remove('centers_step_raw');
$this->session->remove('export_step_raw');
$this->session->remove('export_step');
$this->session->remove('formatter_step_raw');
$this->session->remove('formatter_step');
return $this->redirectToRoute('chill_main_export_download', [
'key' => $key,
'alias' => $alias,
'from_saved' => $savedExport?->getId(),
]);
return $this->redirectToRoute('chill_main_export-generation_wait', ['id' => $exportGeneration->getId()]);
}
private function rebuildData($key, ?SavedExport $savedExport)
/**
* Build the export form data into a way suitable for normalization.
*
* @param string $alias the export alias
* @param array $dataCenters Raw data from center step
* @param array $dataExport Raw data from export step
* @param array $dataFormatter Raw data from formatter step
*/
private function buildExportDataForNormalization(string $alias, ?array $dataCenters, array $dataExport, array $dataFormatter, ?SavedExport $savedExport): array
{
$rawData = $this->rebuildRawData($key);
$alias = $rawData['alias'];
if ($this->filterStatsByCenters) {
$formCenters = $this->createCreateFormExport($alias, 'generate_centers', [], $savedExport);
$formCenters->submit($rawData['centers']);
$dataCenters = $formCenters->getData();
$formCenters = $this->createCreateFormExport($alias, 'generate_centers', [], null);
$formCenters->submit($dataCenters);
$dataAsCollection = $formCenters->getData()['centers'];
$centers = $dataAsCollection['centers'];
$regroupments = $dataAsCollection['regroupments'] ?? [];
$dataCenters = [
'centers' => $centers instanceof Collection ? $centers->toArray() : $centers,
'regroupments' => $regroupments instanceof Collection ? $regroupments->toArray() : $regroupments,
];
} else {
$dataCenters = ['centers' => []];
$dataCenters = ['centers' => [], 'regroupments' => []];
}
$formExport = $this->createCreateFormExport($alias, 'generate_export', $dataCenters, $savedExport);
$formExport->submit($rawData['export']);
$formExport->submit($dataExport);
$dataExport = $formExport->getData();
if (\count($rawData['formatter']) > 0) {
if (\count($dataFormatter) > 0) {
$formFormatter = $this->createCreateFormExport(
$alias,
'generate_formatter',
$dataExport,
$savedExport
);
$formFormatter->submit($rawData['formatter']);
$formFormatter->submit($dataFormatter);
$dataFormatter = $formFormatter->getData();
}
return [$dataCenters, $dataExport, $dataFormatter ?? null];
return [
'centers' => ['centers' => $dataCenters['centers'], 'regroupments' => $dataCenters['regroupments']],
'export' => $dataExport['export']['export'] ?? [],
'filters' => $dataExport['export']['filters'] ?? [],
'aggregators' => $dataExport['export']['aggregators'] ?? [],
'pick_formatter' => $dataExport['export']['pick_formatter']['alias'],
'formatter' => $dataFormatter['formatter'] ?? [],
];
}
/**
@@ -509,7 +391,7 @@ class ExportController extends AbstractController
*
* @return Response
*/
private function selectCentersStep(Request $request, DirectExportInterface|ExportInterface $export, $alias, ?SavedExport $savedExport = null)
private function selectCentersStep(Request $request, DirectExportInterface|ExportInterface $export, $alias, ExportGeneration|SavedExport|null $savedExport = null)
{
if (!$this->filterStatsByCenters) {
return $this->redirectToRoute('chill_main_export_new', [
@@ -522,7 +404,12 @@ class ExportController extends AbstractController
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$form = $this->createCreateFormExport($alias, 'centers', [], $savedExport);
$form = $this->createCreateFormExport(
$alias,
'centers',
$this->exportFormHelper->getDefaultData('centers', $export, []),
$savedExport
);
if (Request::METHOD_POST === $request->getMethod()) {
$form->handleRequest($request);
@@ -538,7 +425,7 @@ class ExportController extends AbstractController
false === $exportManager->isGrantedForElement(
$export,
null,
$exportManager->getPickedCenters($data['centers'])
$this->exportFormHelper->getPickedCenters($data['centers']),
)
) {
throw $this->createAccessDeniedException('you do not have access to this export for those centers');
@@ -548,7 +435,7 @@ class ExportController extends AbstractController
'centers_step_raw',
$request->request->all()
);
$this->session->set('centers_step', $data);
$this->session->set('centers_step', $data['centers']);
return $this->redirectToRoute('chill_main_export_new', [
'step' => $this->getNextStep('centers', $export),
@@ -632,43 +519,15 @@ class ExportController extends AbstractController
}
}
private function rebuildRawData(?string $key): array
{
if (null === $key) {
throw $this->createNotFoundException('key does not exists');
}
if (1 !== $this->redis->exists($key)) {
$this->addFlash('error', $this->translator->trans('This report is not available any more'));
throw $this->createNotFoundException('key does not exists');
}
$serialized = $this->redis->get($key);
if (false === $serialized) {
throw new \LogicException('the key could not be reached from redis');
}
$rawData = \unserialize($serialized);
$this->logger->notice('[export] choices for an export unserialized', [
'key' => $key,
'rawData' => json_encode($rawData, JSON_THROW_ON_ERROR),
]);
return $rawData;
}
private function getSavedExportFromRequest(Request $request): ?SavedExport
private function getSavedExportFromRequest(Request $request): SavedExport|ExportGeneration|null
{
$savedExport = match ($savedExportId = $request->query->get('from_saved', '')) {
'' => null,
default => $this->savedExportRepository->find($savedExportId),
default => $this->savedExportOrExportGenerationRepository->findById($savedExportId),
};
if (null !== $savedExport && !$this->security->isGranted(SavedExportVoter::EDIT, $savedExport)) {
throw new AccessDeniedHttpException('saved export edition not allowed');
if (null !== $savedExport && !$this->security->isGranted(SavedExportVoter::GENERATE, $savedExport)) {
throw new AccessDeniedHttpException('saved export generation not allowed');
}
return $savedExport;

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Export\ExportManager;
use Symfony\Component\HttpFoundation\JsonResponse;
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\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Twig\Environment;
final readonly class ExportGenerationController
{
public function __construct(
private Security $security,
private Environment $twig,
private SerializerInterface $serializer,
private ExportManager $exportManager,
) {}
#[Route('/{_locale}/main/export-generation/{id}/wait', methods: ['GET'], name: 'chill_main_export-generation_wait')]
public function wait(ExportGeneration $exportGeneration): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users can download an export');
}
$export = $this->exportManager->getExport($exportGeneration->getExportAlias());
return new Response(
$this->twig->render('@ChillMain/ExportGeneration/wait.html.twig', ['exportGeneration' => $exportGeneration, 'export' => $export]),
);
}
#[Route('/api/1.0/main/export-generation/{id}/object', methods: ['GET'])]
public function objectStatus(ExportGeneration $exportGeneration): JsonResponse
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users can download an export');
}
return new JsonResponse(
$this->serializer->serialize(
$exportGeneration,
'json',
[AbstractNormalizer::GROUPS => ['read']],
),
json: true,
);
}
}

View File

@@ -0,0 +1,62 @@
<?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\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
class ExportGenerationCreateFromSavedExportController
{
public function __construct(
private readonly Security $security,
private readonly EntityManagerInterface $entityManager,
private readonly MessageBusInterface $messageBus,
private readonly ClockInterface $clock,
private readonly SerializerInterface $serializer,
) {}
#[Route('/api/1.0/main/export/export-generation/create-from-saved-export/{id}', methods: ['POST'])]
public function __invoke(SavedExport $export): JsonResponse
{
if (!$this->security->isGranted(SavedExportVoter::GENERATE, $export)) {
throw new AccessDeniedHttpException('Not allowed to generate an export from this saved export');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Only users can create exports');
}
$exportGeneration = ExportGeneration::fromSavedExport($export, $this->clock->now()->add(new \DateInterval('P6M')));
$this->entityManager->persist($exportGeneration);
$this->entityManager->flush();
$this->messageBus->dispatch(new ExportRequestGenerationMessage($exportGeneration, $user));
return new JsonResponse(
$this->serializer->serialize($exportGeneration, 'json', ['groups' => ['read']]),
json: true,
);
}
}

View File

@@ -0,0 +1,62 @@
<?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\User;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
final readonly class ExportIndexController
{
public function __construct(
private ExportManager $exportManager,
private Environment $twig,
private ExportGenerationRepository $exportGenerationRepository,
private Security $security,
) {}
/**
* Render the list of available exports.
*/
#[Route(path: '/{_locale}/exports/', name: 'chill_main_export_index')]
public function indexAction(ExportController $exportController): Response
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Only regular user can see this page');
}
if (!$this->security->isGranted(ChillExportVoter::COMPOSE_EXPORT)) {
throw new AccessDeniedHttpException(sprintf('Require the %s role', ChillExportVoter::COMPOSE_EXPORT));
}
$exports = $this->exportManager->getExportsGrouped(true);
$lastExecutions = [];
foreach ($this->exportManager->getExports() as $alias => $export) {
$lastExecutions[$alias] = $this->exportGenerationRepository->findExportGenerationByAliasAndUser($alias, $user, 5);
}
return new Response(
$this->twig->render('@ChillMain/Export/layout.html.twig', [
'grouped_exports' => $exports,
'last_executions' => $lastExecutions,
]),
);
}
}

View File

@@ -11,13 +11,13 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportDescriptionHelper;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Security\Authorization\ExportGenerationVoter;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@@ -25,16 +25,28 @@ use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class SavedExportController
final readonly class SavedExportController
{
public function __construct(private readonly \Twig\Environment $templating, private readonly EntityManagerInterface $entityManager, private readonly ExportManager $exportManager, private readonly FormFactoryInterface $formFactory, private readonly SavedExportRepositoryInterface $savedExportRepository, private readonly Security $security, private readonly SessionInterface $session, private readonly TranslatorInterface $translator, private readonly UrlGeneratorInterface $urlGenerator) {}
public function __construct(
private \Twig\Environment $templating,
private EntityManagerInterface $entityManager,
private ExportManager $exportManager,
private FormFactoryInterface $formFactory,
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')]
public function delete(SavedExport $savedExport, Request $request): Response
@@ -51,7 +63,10 @@ class SavedExportController
$this->entityManager->remove($savedExport);
$this->entityManager->flush();
$this->session->getFlashBag()->add('success', $this->translator->trans('saved_export.Export is deleted'));
$session = $request->getSession();
if ($session instanceof Session) {
$session->getFlashBag()->add('success', new TranslatableMessage('saved_export.Export is deleted'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_list_my')
@@ -69,6 +84,105 @@ class SavedExportController
);
}
#[Route(path: '/exports/saved/create-from-export-generation/{id}/new', name: 'chill_main_export_saved_create_from_export_generation')]
public function createFromExportGeneration(ExportGeneration $exportGeneration, Request $request): Response
{
if (!$this->security->isGranted(ExportGenerationVoter::VIEW, $exportGeneration)) {
throw new AccessDeniedHttpException();
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('only regular user can create a saved export');
}
$export = $this->exportManager->getExport($exportGeneration->getExportAlias());
$title = $export->getTitle() instanceof TranslatableInterface ? $export->getTitle()->trans($this->translator) :
$this->translator->trans($export->getTitle());
$savedExport = new SavedExport();
$savedExport
->setExportAlias($exportGeneration->getExportAlias())
->setUser($user)
->setOptions($exportGeneration->getOptions())
->setTitle(
$request->query->has('title') ? $request->query->get('title') : $title
);
if ($exportGeneration->isLinkedToSavedExport()) {
$savedExport->setDescription($exportGeneration->getSavedExport()->getDescription());
} else {
$savedExport->setDescription(
implode(
"\n",
array_map(
fn (string $item) => '- '.$item."\n",
$this->exportDescriptionHelper->describe($savedExport->getExportAlias(), $savedExport->getOptions(), includeExportTitle: false)
)
)
);
}
return $this->handleEdit($savedExport, $request, true);
}
#[Route(path: '/exports/saved/duplicate-from-saved-export/{id}/new', name: 'chill_main_export_saved_duplicate')]
public function duplicate(SavedExport $previousSavedExport, Request $request): Response
{
$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())
->setUser($user)
->setOptions($previousSavedExport->getOptions())
->setDescription($previousSavedExport->getDescription())
->setTitle(
$request->query->has('title') ?
$request->query->get('title') :
$previousSavedExport->getTitle().' ('.$this->translator->trans('saved_export.Duplicated').' '.(new \DateTimeImmutable('now'))->format('d-m-Y H:i:s').')'
);
return $this->handleEdit($savedExport, $request);
}
private function handleEdit(SavedExport $savedExport, Request $request, bool $showWarningAutoGeneratedDescription = false): Response
{
$form = $this->formFactory->create(SavedExportType::class, $savedExport);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($savedExport);
$this->entityManager->flush();
if (($session = $request->getSession()) instanceof Session) {
$session->getFlashBag()->add('success', new TranslatableMessage('saved_export.Saved export is saved!'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_list_my'),
);
}
return new Response(
$this->templating->render(
'@ChillMain/SavedExport/new.html.twig',
[
'form' => $form->createView(),
'showWarningAutoGeneratedDescription' => $showWarningAutoGeneratedDescription,
],
),
);
}
#[Route(path: '/{_locale}/exports/saved/{id}/edit', name: 'chill_main_export_saved_edit')]
public function edit(SavedExport $savedExport, Request $request): Response
{
@@ -83,10 +197,12 @@ class SavedExportController
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->session->getFlashBag()->add('success', $this->translator->trans('saved_export.Saved export is saved!'));
if (($session = $request->getSession()) instanceof Session) {
$session->getFlashBag()->add('success', new TranslatableMessage('saved_export.Saved export is saved!'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_list_my')
$this->urlGenerator->generate('chill_main_export_saved_list_my'),
);
}
@@ -95,45 +211,37 @@ class SavedExportController
'@ChillMain/SavedExport/edit.html.twig',
[
'form' => $form->createView(),
]
)
],
),
);
}
#[Route(path: '/{_locale}/exports/saved/my', name: 'chill_main_export_saved_list_my')]
public function list(): Response
#[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
{
$user = $this->security->getUser();
if (!$this->security->isGranted('ROLE_USER') || !$user instanceof User) {
throw new AccessDeniedHttpException();
if (!$this->security->isGranted(SavedExportVoter::DUPLICATE, $savedExport)) {
throw new AccessDeniedHttpException('You are not allowed to access this saved export');
}
$exports = $this->savedExportRepository->findByUser($user, ['title' => 'ASC']);
// 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];
if (!$this->security->isGranted(ExportGenerationVoter::VIEW, $exportGeneration)) {
throw new AccessDeniedHttpException('You are not allowed to access this export generation');
}
ksort($exportsGrouped);
if ($savedExport->getExportAlias() !== $exportGeneration->getExportAlias()) {
throw new UnprocessableEntityHttpException('export alias does not match');
}
return new Response(
$this->templating->render(
'@ChillMain/SavedExport/index.html.twig',
[
'grouped_exports' => $exportsGrouped,
'total' => \count($exports),
]
)
$savedExport->setOptions($exportGeneration->getOptions());
$this->entityManager->flush();
$session = $request->getSession();
if ($session instanceof Session) {
$session->getFlashBag()->add('success', new TranslatableMessage('saved_export.Options updated successfully'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_export_saved_edit', ['id' => $savedExport->getId()]),
);
}
}

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

@@ -11,7 +11,10 @@ declare(strict_types=1);
namespace Chill\MainBundle\DataFixtures\ORM;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\GroupCenter;
use Chill\MainBundle\Entity\PermissionsGroup;
use Chill\MainBundle\Entity\RoleScope;
use Chill\MainBundle\Entity\User;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
@@ -62,6 +65,15 @@ class LoadUsers extends AbstractFixture implements ContainerAwareInterface, Orde
public function load(ObjectManager $manager): void
{
$roleScope = new RoleScope();
$roleScope->setRole('CHILL_MAIN_COMPOSE_EXPORT');
$permissionGroup = new PermissionsGroup();
$permissionGroup->setName('export');
$permissionGroup->addRoleScope($roleScope);
$manager->persist($roleScope);
$manager->persist($permissionGroup);
foreach (self::$refs as $username => $params) {
$user = new User();
@@ -81,7 +93,14 @@ class LoadUsers extends AbstractFixture implements ContainerAwareInterface, Orde
->setEmail(sprintf('%s@chill.social', \str_replace(' ', '', (string) $username)));
foreach ($params['groupCenterRefs'] as $groupCenterRef) {
$user->addGroupCenter($this->getReference($groupCenterRef, GroupCenter::class));
$user->addGroupCenter($gc = $this->getReference($groupCenterRef, GroupCenter::class));
$exportGroupCenter = new GroupCenter();
$exportGroupCenter->setPermissionsGroup($permissionGroup);
$exportGroupCenter->setCenter($gc->getCenter());
$manager->persist($exportGroupCenter);
$user->addGroupCenter($exportGroupCenter);
}
echo 'Creating user '.$username."... \n";

View File

@@ -78,6 +78,7 @@ use Chill\MainBundle\Form\RegroupmentType;
use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
use Ramsey\Uuid\Doctrine\UuidType;
use Symfony\Component\Config\FileLocator;
@@ -332,6 +333,9 @@ class ChillMainExtension extends Extension implements
'strategy' => 'unanimous',
'allow_if_all_abstain' => false,
],
'role_hierarchy' => [
ChillExportVoter::COMPOSE_EXPORT => ChillExportVoter::GENERATE_SAVED_EXPORT,
],
]);
// add crud api

View File

@@ -1,102 +0,0 @@
<?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\DependencyInjection\CompilerPass;
use Chill\MainBundle\Export\ExportManager;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
/**
* Compiles the services tagged with :.
*
* - chill.export
* - chill.export_formatter
* - chill.export_aggregator
* - chill.export_filter
* - chill.export_elements_provider
*/
class ExportsCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
if (!$container->has(ExportManager::class)) {
throw new \LogicException('service '.ExportManager::class.' is not defined. It is required by ExportsCompilerPass');
}
$chillManagerDefinition = $container->findDefinition(
ExportManager::class
);
$this->compileFormatters($chillManagerDefinition, $container);
$this->compileExportElementsProvider($chillManagerDefinition, $container);
}
private function compileExportElementsProvider(
Definition $chillManagerDefinition,
ContainerBuilder $container,
) {
$taggedServices = $container->findTaggedServiceIds(
'chill.export_elements_provider'
);
$knownAliases = [];
foreach ($taggedServices as $id => $tagAttributes) {
foreach ($tagAttributes as $attributes) {
if (!isset($attributes['prefix'])) {
throw new \LogicException("the 'prefix' attribute is missing in your service '{$id}' definition");
}
if (array_search($attributes['prefix'], $knownAliases, true)) {
throw new \LogicException('There is already a chill.export_elements_provider service with prefix '.$attributes['prefix'].'. Choose another prefix.');
}
$knownAliases[] = $attributes['prefix'];
$chillManagerDefinition->addMethodCall(
'addExportElementsProvider',
[new Reference($id), $attributes['prefix']]
);
}
}
}
private function compileFormatters(
Definition $chillManagerDefinition,
ContainerBuilder $container,
) {
$taggedServices = $container->findTaggedServiceIds(
'chill.export_formatter'
);
$knownAliases = [];
foreach ($taggedServices as $id => $tagAttributes) {
foreach ($tagAttributes as $attributes) {
if (!isset($attributes['alias'])) {
throw new \LogicException("the 'alias' attribute is missing in your service '{$id}' definition");
}
if (array_search($attributes['alias'], $knownAliases, true)) {
throw new \LogicException('There is already a chill.export_formatter service with alias '.$attributes['alias'].'. Choose another alias.');
}
$knownAliases[] = $attributes['alias'];
$chillManagerDefinition->addMethodCall(
'addFormatter',
[new Reference($id), $attributes['alias']]
);
}
}
}
}

View File

@@ -23,12 +23,12 @@ class Unaccent extends FunctionNode
{
private ?\Doctrine\ORM\Query\AST\Node $string = null;
public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker)
public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker): string
{
return 'UNACCENT('.$this->string->dispatch($sqlWalker).')';
}
public function parse(\Doctrine\ORM\Query\Parser $parser)
public function parse(\Doctrine\ORM\Query\Parser $parser): void
{
$parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
$parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);

View File

@@ -0,0 +1,150 @@
<?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\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Contains the single execution of an export.
*
* Attached to a stored object, which will contains the result of the execution. The status of the stored object
* is the status of the export generation.
*
* Generated exports should be deleted after a certain time, given by the column `deletedAt`. The stored object can be
* deleted after the export generation removal.
*/
#[ORM\Entity()]
#[ORM\Table('chill_main_export_generation')]
#[Serializer\DiscriminatorMap('type', ['export_generation' => ExportGeneration::class])]
class ExportGeneration implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[Serializer\Groups(['read'])]
private UuidInterface $id;
#[ORM\ManyToOne(targetEntity: StoredObject::class, cascade: ['persist', 'refresh'])]
#[ORM\JoinColumn(nullable: false)]
#[Serializer\Groups(['read'])]
private StoredObject $storedObject;
public function __construct(
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
#[Serializer\Groups(['read'])]
private string $exportAlias,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
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 ExportGeneration 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, onDelete: 'SET NULL')]
private ?SavedExport $savedExport = null,
) {
$this->id = Uuid::uuid4();
$this->storedObject = new StoredObject(StoredObject::STATUS_PENDING);
}
public function setDeleteAt(?\DateTimeImmutable $deleteAt): ExportGeneration
{
$this->deleteAt = $deleteAt;
return $this;
}
public function getDeleteAt(): ?\DateTimeImmutable
{
return $this->deleteAt;
}
public function getId(): UuidInterface
{
return $this->id;
}
public function getStoredObject(): StoredObject
{
return $this->storedObject;
}
public function getExportAlias(): string
{
return $this->exportAlias;
}
public function getOptions(): array
{
return $this->options;
}
public function getSavedExport(): ?SavedExport
{
return $this->savedExport;
}
#[Serializer\Groups(['read'])]
#[Serializer\SerializedName('status')]
public function getStatus(): string
{
return $this->getStoredObject()->getStatus();
}
public function setSavedExport(SavedExport $savedExport): self
{
$this->savedExport = $savedExport;
return $this;
}
public function isLinkedToSavedExport(): bool
{
return null !== $this->savedExport;
}
/**
* 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
{
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;
}
}

View File

@@ -50,4 +50,9 @@ class SimpleGeographicalUnitDTO
#[Serializer\Groups(['read'])]
public int $layerId,
) {}
public function getId(): int
{
return $this->id;
}
}

View File

@@ -102,4 +102,22 @@ class Regroupment
return $this;
}
/**
* Return true if the given center is contained into this regroupment.
*/
public function containsCenter(Center $center): bool
{
return $this->centers->contains($center);
}
/**
* Return true if at least one of the given centers is contained into this regroupment.
*
* @param list<Center> $centers
*/
public function containsAtLeastOneCenter(array $centers): bool
{
return array_reduce($centers, fn (bool $carry, Center $center) => $carry || $this->containsCenter($center), false);
}
}

View File

@@ -15,6 +15,9 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
@@ -50,9 +53,25 @@ class SavedExport implements TrackCreationInterface, TrackUpdateInterface
#[ORM\ManyToOne(targetEntity: User::class)]
private User $user;
/**
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_saved_export_users')]
private Collection $sharedWithUsers;
/**
* @var Collection<int, UserGroup>
*/
#[ORM\ManyToMany(targetEntity: UserGroup::class)]
#[ORM\JoinTable(name: 'chill_main_saved_export_usergroups')]
private Collection $sharedWithGroups;
public function __construct()
{
$this->id = Uuid::uuid4();
$this->sharedWithUsers = new ArrayCollection();
$this->sharedWithGroups = new ArrayCollection();
}
public function getDescription(): string
@@ -119,4 +138,71 @@ class SavedExport implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
public function addShare(User|UserGroup $shareUser): SavedExport
{
if ($shareUser instanceof User) {
if (!$this->sharedWithUsers->contains($shareUser)) {
$this->sharedWithUsers->add($shareUser);
}
} else {
if (!$this->sharedWithGroups->contains($shareUser)) {
$this->sharedWithGroups->add($shareUser);
}
}
return $this;
}
public function removeShare(User|UserGroup $shareUser): SavedExport
{
if ($shareUser instanceof User) {
$this->sharedWithUsers->removeElement($shareUser);
} else {
$this->sharedWithGroups->removeElement($shareUser);
}
return $this;
}
/**
* @return ReadableCollection<int, User|UserGroup>
*/
public function getShare(): ReadableCollection
{
return new ArrayCollection([
...$this->sharedWithUsers->toArray(),
...$this->sharedWithGroups->toArray(),
]);
}
/**
* Return true if shared with at least one user or one group.
*/
public function isShared(): bool
{
return $this->sharedWithUsers->count() > 0 || $this->sharedWithGroups->count() > 0;
}
/**
* Determines if the user is shared with either directly or through a group.
*
* @param User $user the user to check
*
* @return bool returns true if the user is shared with directly or via group, otherwise false
*/
public function isSharedWithUser(User $user): bool
{
if ($this->sharedWithUsers->contains($user)) {
return true;
}
foreach ($this->sharedWithGroups as $group) {
if ($group->contains($user)) {
return true;
}
}
return false;
}
}

View File

@@ -21,6 +21,19 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Represents a user group entity in the system.
*
* This class is used for managing user groups, including their relationships
* with users, administrative users, and additional metadata such as colors and labels.
*
* Groups may be configured to have mutual exclusion properties based on an
* exclusion key. This ensures that groups sharing the same key cannot coexist
* in certain relationship contexts.
*
* Groups may be related to a UserJob. In that case, a cronjob task ensure that the members of the groups are
* automatically synced with this group. Such groups are also automatically created by the cronjob.
*/
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_user_group')]
// this discriminator key is required for automated denormalization
@@ -71,6 +84,13 @@ class UserGroup
#[Assert\Email]
private string $email = '';
/**
* UserJob to which the group is related.
*/
#[ORM\ManyToOne(targetEntity: UserJob::class)]
#[ORM\JoinColumn(nullable: true)]
private ?UserJob $userJob = null;
public function __construct()
{
$this->adminUsers = new ArrayCollection();
@@ -209,6 +229,21 @@ class UserGroup
return '' !== $this->email;
}
public function hasUserJob(): bool
{
return null !== $this->userJob;
}
public function getUserJob(): ?UserJob
{
return $this->userJob;
}
public function setUserJob(?UserJob $userJob): void
{
$this->userJob = $userJob;
}
/**
* Checks if the current object is an instance of the UserGroup class.
*

View File

@@ -12,25 +12,42 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* Interface for Aggregators.
*
* Aggregators gather result of a query. Most of the time, it will add
* a GROUP BY clause.
*
* @template D of array
*/
interface AggregatorInterface extends ModifierInterface
{
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
public function buildForm(FormBuilderInterface $builder): void;
/**
* Get the default data, that can be use as "data" for the form.
*
* @return D
*/
public function getFormDefaultData(): array;
/**
* @param D $formData
*/
public function normalizeFormData(array $formData): array;
/**
* @return D
*/
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
/**
* get a callable which will be able to transform the results into
* viewable and understable string.
@@ -74,9 +91,9 @@ interface AggregatorInterface extends ModifierInterface
* @param string $key The column key, as added in the query
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
*
* @return \Closure where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
* @return callable(mixed $value): (string|int|\DateTimeInterface|TranslatableInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
*/
public function getLabels($key, array $values, mixed $data);
public function getLabels(string $key, array $values, mixed $data): callable;
/**
* give the list of keys the current export added to the queryBuilder in
@@ -85,7 +102,9 @@ interface AggregatorInterface extends ModifierInterface
* Example: if your query builder will contains `SELECT count(id) AS count_id ...`,
* this function will return `array('count_id')`.
*
* @param mixed[] $data the data from the export's form (added by self::buildForm)
* @param D $data the data from the export's form (added by self::buildForm)
*
* @return list<string>
*/
public function getQueryKeys($data);
public function getQueryKeys(array $data): array;
}

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

@@ -28,8 +28,16 @@ interface DirectExportInterface extends ExportElementInterface
/**
* Generate the export.
*
* @return FormattedExportGeneration
*/
public function generate(array $acl, array $data = []): Response;
public function generate(array $acl, array $data, ExportGenerationContext $context): Response|FormattedExportGeneration;
public function normalizeFormData(array $formData): array;
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
/**
* get a description, which will be used in UI (and translated).

View File

@@ -0,0 +1,14 @@
<?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\Exception;
class ExportGenerationException extends ExportRuntimeException {}

View File

@@ -0,0 +1,14 @@
<?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\Exception;
class ExportLogicException extends \LogicException {}

View File

@@ -0,0 +1,14 @@
<?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\Exception;
class ExportRuntimeException extends \RuntimeException {}

View File

@@ -0,0 +1,20 @@
<?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\Exception;
class UnauthorizedGenerationException extends ExportGenerationException
{
public function __construct(string $message, ?\Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}

View File

@@ -0,0 +1,128 @@
<?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\Center;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Form\Type\Export\AggregatorType;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FilterType;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\RegroupmentRepositoryInterface;
/**
* @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter: string, formatter: array{form: array<string, mixed>, version: int}}
*/
class ExportConfigNormalizer
{
public function __construct(
private readonly ExportManager $exportManager,
private readonly CenterRepositoryInterface $centerRepository,
private readonly RegroupmentRepositoryInterface $regroupmentRepository,
) {}
/**
* @return NormalizedData
*/
public function normalizeConfig(string $exportAlias, array $formData): array
{
$exportData = $formData[ExportType::EXPORT_KEY];
$export = $this->exportManager->getExport($exportAlias);
$serialized = [
'export' => [
'form' => $export->normalizeFormData($exportData),
'version' => $export->getNormalizationVersion(),
],
];
$serialized['centers'] = [
'centers' => array_values(array_map(static fn (Center $center) => $center->getId(), $formData['centers']['centers'] ?? [])),
'regroupments' => array_values(array_map(static fn (Regroupment $group) => $group->getId(), $formData['centers']['regroupments'] ?? [])),
];
$filtersSerialized = [];
foreach ($formData[ExportType::FILTER_KEY] as $alias => $filterData) {
$filter = $this->exportManager->getFilter($alias);
$filtersSerialized[$alias][FilterType::ENABLED_FIELD] = (bool) $filterData[FilterType::ENABLED_FIELD];
if ($filterData[FilterType::ENABLED_FIELD]) {
$filtersSerialized[$alias]['form'] = $filter->normalizeFormData($filterData['form']);
$filtersSerialized[$alias]['version'] = $filter->getNormalizationVersion();
}
}
$serialized['filters'] = $filtersSerialized;
$aggregatorsSerialized = [];
foreach ($formData[ExportType::AGGREGATOR_KEY] as $alias => $aggregatorData) {
$aggregator = $this->exportManager->getAggregator($alias);
$aggregatorsSerialized[$alias][FilterType::ENABLED_FIELD] = (bool) $aggregatorData[AggregatorType::ENABLED_FIELD];
if ($aggregatorData[AggregatorType::ENABLED_FIELD]) {
$aggregatorsSerialized[$alias]['form'] = $aggregator->normalizeFormData($aggregatorData['form']);
$aggregatorsSerialized[$alias]['version'] = $aggregator->getNormalizationVersion();
}
}
$serialized['aggregators'] = $aggregatorsSerialized;
$serialized['pick_formatter'] = $formData['pick_formatter'];
$formatter = $this->exportManager->getFormatter($formData['pick_formatter']);
$serialized['formatter']['form'] = $formatter->normalizeFormData($formData['formatter']);
$serialized['formatter']['version'] = $formatter->getNormalizationVersion();
return $serialized;
}
/**
* @param NormalizedData $serializedData
* @param bool $replaceDisabledByDefaultData if true, when a filter is not enabled, the formDefaultData is set
*/
public function denormalizeConfig(string $exportAlias, array $serializedData, bool $replaceDisabledByDefaultData = false): array
{
$export = $this->exportManager->getExport($exportAlias);
$formater = $this->exportManager->getFormatter($serializedData['pick_formatter']);
$filtersConfig = [];
foreach ($serializedData['filters'] as $alias => $filterData) {
$aggregator = $this->exportManager->getFilter($alias);
$filtersConfig[$alias]['enabled'] = $filterData['enabled'];
if ($filterData['enabled']) {
$filtersConfig[$alias]['form'] = $aggregator->denormalizeFormData($filterData['form'], $filterData['version']);
} elseif ($replaceDisabledByDefaultData) {
$filtersConfig[$alias]['form'] = $aggregator->getFormDefaultData();
}
}
$aggregatorsConfig = [];
foreach ($serializedData['aggregators'] as $alias => $aggregatorData) {
$aggregator = $this->exportManager->getAggregator($alias);
$aggregatorsConfig[$alias]['enabled'] = $aggregatorData['enabled'];
if ($aggregatorData['enabled']) {
$aggregatorsConfig[$alias]['form'] = $aggregator->denormalizeFormData($aggregatorData['form'], $aggregatorData['version']);
} elseif ($replaceDisabledByDefaultData) {
$aggregatorsConfig[$alias]['form'] = $aggregator->getFormDefaultData();
}
}
return [
'export' => $export->denormalizeFormData($serializedData['export']['form'], $serializedData['export']['version']),
'filters' => $filtersConfig,
'aggregators' => $aggregatorsConfig,
'pick_formatter' => $serializedData['pick_formatter'],
'formatter' => $formater->denormalizeFormData($serializedData['formatter']['form'], $serializedData['formatter']['version']),
'centers' => [
'centers' => array_values(array_filter(array_map(fn (int $id) => $this->centerRepository->find($id), $serializedData['centers']['centers']), fn ($item) => null !== $item)),
'regroupments' => array_values(array_filter(array_map(fn (int $id) => $this->regroupmentRepository->find($id), $serializedData['centers']['regroupments']), fn ($item) => null !== $item)),
],
];
}
}

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;
class ExportConfigProcessor
{
public function __construct(private readonly ExportManager $exportManager) {}
/**
* @return iterable<string, AggregatorInterface>
*/
public function retrieveUsedAggregators(mixed $data): iterable
{
if (null === $data) {
return [];
}
foreach ($data as $alias => $aggregatorData) {
if ($this->exportManager->hasAggregator($alias) && true === $aggregatorData['enabled']) {
yield $alias => $this->exportManager->getAggregator($alias);
}
}
}
/**
* @return iterable<string, FilterInterface>
*/
public function retrieveUsedFilters(mixed $data): iterable
{
if (null === $data) {
return [];
}
foreach ($data as $alias => $filterData) {
if ($this->exportManager->hasFilter($alias) && true === $filterData['enabled']) {
yield $alias => $this->exportManager->getFilter($alias);
}
}
}
}

View File

@@ -0,0 +1,189 @@
<?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 Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\Persistence\ObjectRepository;
/**
* Provides utilities for normalizing and denormalizing data entities and dates.
*/
trait ExportDataNormalizerTrait
{
/**
* Normalizes a Doctrine entity or a collection of entities to extract their identifiers.
*
* @param object|list<object>|null $entity the entity or collection of entities to normalize
*
* @return array|int|string Returns the identifier(s) of the entity or entities. If an array of entities is provided,
* an array of their identifiers is returned. If a single entity is provided, its identifier
* is returned. If null, returns an empty value.
*/
private function normalizeDoctrineEntity(object|array|null $entity): array|int|string
{
if (is_array($entity)) {
return array_values(array_filter(array_map(static fn (object $entity) => $entity->getId(), $entity), fn ($value) => null !== $value));
}
if ($entity instanceof Collection) {
return $this->normalizeDoctrineEntity($entity->toArray());
}
return $entity?->getId();
}
/**
* Denormalizes a Doctrine entity by fetching it from the provided repository based on the given ID(s).
*
* @param list<int>|int|string $id the identifier(s) of the entity to find
* @param ObjectRepository $repository the Doctrine repository to query
*
* @return object|array<object> the found entity or an array of entities if multiple IDs are provided
*
* @throws \UnexpectedValueException when the entity with the given ID does not exist
*/
private function denormalizeDoctrineEntity(array|int|string $id, ObjectRepository $repository): object|array
{
if (is_array($id)) {
if ([] === $id) {
return [];
}
return $repository->findBy(['id' => $id]);
}
if (null === $object = $repository->find($id)) {
throw new \UnexpectedValueException(sprintf('Object with id "%s" does not exist.', $id));
}
return $object;
}
/**
* Normalizer the "user or me" values.
*
* @param 'me'|User|iterable<'me'|User> $user
*
* @return int|'me'|list<'me'|int>
*/
private function normalizeUserOrMe(string|User|iterable $user): int|string|array
{
if (is_iterable($user)) {
$users = [];
foreach ($user as $u) {
$users[] = $this->normalizeUserOrMe($u);
}
return $users;
}
if ('me' === $user) {
return $user;
}
return $user->getId();
}
/**
* @param 'me'|int|iterable<'me'|int> $userId
*
* @return 'me'|User|array|null
*/
private function denormalizeUserOrMe(string|int|iterable $userId, UserRepositoryInterface $userRepository): string|User|array|null
{
if (is_iterable($userId)) {
$users = [];
foreach ($userId as $id) {
$users[] = $this->denormalizeUserOrMe($id, $userRepository);
}
return $users;
}
if ('me' === $userId) {
return 'me';
}
return $userRepository->find($userId);
}
/**
* @param 'me'|User|iterable<'me'|User> $user
*
* @return User|list<User>
*/
private function userOrMe(string|User|iterable $user, ExportGenerationContext $context): User|array
{
if (is_iterable($user)) {
$users = [];
foreach ($user as $u) {
$users[] = $this->userOrMe($u, $context);
}
return array_values(
array_filter($users, static fn (?User $user) => null !== $user)
);
}
if ('me' === $user) {
return $context->byUser;
}
return $user;
}
/**
* Normalizes a provided date into a specific string format.
*
* @param \DateTimeImmutable|\DateTime $date the date instance to normalize
*
* @return string a formatted string containing the type and formatted date
*/
private function normalizeDate(\DateTimeImmutable|\DateTime $date): string
{
return sprintf(
'%s,%s',
$date instanceof \DateTimeImmutable ? 'imm1' : 'mut1',
$date->format('d-m-Y-H:i:s.u e'),
);
}
/**
* Denormalizes a string back into a DateTime instance.
*
* The string is expected to contain a kind selector (e.g., 'imm1' or 'mut1')
* to determine the type of DateTime object (immutable or mutable) followed by a date format.
*
* @param string $date the string to be denormalized, containing the kind selector and formatted date
*
* @return \DateTimeImmutable|\DateTime a DateTime instance created from the given string
*
* @throws \UnexpectedValueException if the kind selector or date format is invalid
*/
private function denormalizeDate(string $date): \DateTimeImmutable|\DateTime
{
$format = 'd-m-Y-H:i:s.u e';
$denormalized = match (substr($date, 0, 4)) {
'imm1' => \DateTimeImmutable::createFromFormat($format, substr($date, 5)),
'mut1' => \DateTime::createFromFormat($format, substr($date, 5)),
default => throw new \UnexpectedValueException(sprintf('Unexpected format for the kind selector: %s', substr($date, 0, 4))),
};
if (false === $denormalized) {
throw new \UnexpectedValueException(sprintf('Unexpected date format: %s', substr($date, 5)));
}
return $denormalized;
}
}

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

@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* The common methods between different object used to build export (i.e. : ExportInterface,
* FilterInterface, AggregatorInterface).
@@ -19,8 +21,6 @@ interface ExportElementInterface
{
/**
* get a title, which will be used in UI (and translated).
*
* @return string
*/
public function getTitle();
public function getTitle(): string|TranslatableInterface;
}

View File

@@ -31,5 +31,5 @@ interface ExportElementValidatedInterface
* validate the form's data and, if required, build a contraint
* violation on the data.
*/
public function validateForm(mixed $data, ExecutionContextInterface $context);
public function validateForm(mixed $data, ExecutionContextInterface $context): void;
}

View File

@@ -21,7 +21,7 @@ namespace Chill\MainBundle\Export;
interface ExportElementsProviderInterface
{
/**
* @return ExportElementInterface[]
* @return iterable<ExportElementInterface>
*/
public function getExportElements();
public function getExportElements(): iterable;
}

View File

@@ -11,27 +11,28 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FilterType;
use Chill\MainBundle\Form\Type\Export\FormatterType;
use Chill\MainBundle\Form\Type\Export\PickCenterType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormFactoryInterface;
use Chill\MainBundle\Service\Regroupement\CenterRegroupementResolver;
use Doctrine\Common\Collections\Collection;
final readonly class ExportFormHelper
{
public function __construct(
private AuthorizationHelperForCurrentUserInterface $authorizationHelper,
private ExportManager $exportManager,
private FormFactoryInterface $formFactory,
private ExportConfigNormalizer $configNormalizer,
private CenterRegroupementResolver $centerRegroupementResolver,
) {}
public function getDefaultData(string $step, DirectExportInterface|ExportInterface $export, array $options = []): array
{
return match ($step) {
'centers', 'generate_centers' => ['centers' => $this->authorizationHelper->getReachableCenters($export->requiredRole())],
'centers', 'generate_centers' => ['centers' => $this->authorizationHelper->getReachableCenters($export->requiredRole()), 'regroupments' => []],
'export', 'generate_export' => ['export' => $this->getDefaultDataStepExport($export, $options)],
'formatter', 'generate_formatter' => ['formatter' => $this->getDefaultDataStepFormatter($options)],
default => throw new \LogicException('step not allowed : '.$step),
@@ -91,80 +92,68 @@ final readonly class ExportFormHelper
}
public function savedExportDataToFormData(
SavedExport $savedExport,
ExportGeneration|SavedExport $savedExport,
string $step,
array $formOptions = [],
): array {
return match ($step) {
'centers', 'generate_centers' => $this->savedExportDataToFormDataStepCenter($savedExport),
'export', 'generate_export' => $this->savedExportDataToFormDataStepExport($savedExport, $formOptions),
'formatter', 'generate_formatter' => $this->savedExportDataToFormDataStepFormatter($savedExport, $formOptions),
'export', 'generate_export' => $this->savedExportDataToFormDataStepExport($savedExport),
'formatter', 'generate_formatter' => $this->savedExportDataToFormDataStepFormatter($savedExport),
default => throw new \LogicException('this step is not allowed: '.$step),
};
}
private function savedExportDataToFormDataStepCenter(
SavedExport $savedExport,
ExportGeneration|SavedExport $savedExport,
): array {
$builder = $this->formFactory
->createBuilder(
FormType::class,
[],
[
'csrf_protection' => false,
]
);
$builder->add('centers', PickCenterType::class, [
'export_alias' => $savedExport->getExportAlias(),
]);
$form = $builder->getForm();
$form->submit($savedExport->getOptions()['centers']);
return $form->getData();
return [
'centers' => $this->configNormalizer->denormalizeConfig($savedExport->getExportAlias(), $savedExport->getOptions(), true)['centers'],
];
}
private function savedExportDataToFormDataStepExport(
SavedExport $savedExport,
array $formOptions,
ExportGeneration|SavedExport $savedExport,
): array {
$builder = $this->formFactory
->createBuilder(
FormType::class,
[],
[
'csrf_protection' => false,
]
);
$data = $this->configNormalizer->denormalizeConfig($savedExport->getExportAlias(), $savedExport->getOptions(), true);
$builder->add('export', ExportType::class, [
'export_alias' => $savedExport->getExportAlias(), ...$formOptions,
]);
$form = $builder->getForm();
$form->submit($savedExport->getOptions()['export']);
return $form->getData();
return [
'export' => [
'export' => $data['export'],
'filters' => $data['filters'],
'pick_formatter' => ['alias' => $data['pick_formatter']],
'aggregators' => $data['aggregators'],
],
];
}
private function savedExportDataToFormDataStepFormatter(
SavedExport $savedExport,
array $formOptions,
ExportGeneration|SavedExport $savedExport,
): array {
$builder = $this->formFactory
->createBuilder(
FormType::class,
[],
[
'csrf_protection' => false,
]
);
$data = $this->configNormalizer->denormalizeConfig($savedExport->getExportAlias(), $savedExport->getOptions(), true);
$builder->add('formatter', FormatterType::class, [
'export_alias' => $savedExport->getExportAlias(), ...$formOptions,
]);
$form = $builder->getForm();
$form->submit($savedExport->getOptions()['formatter']);
return [
'formatter' => $data['formatter'],
];
}
return $form->getData();
/**
* Get the Center picked by the user for this export. The data are
* extracted from the PickCenterType data.
*
* @param array $data the data as given by the @see{Chill\MainBundle\Form\Type\Export\PickCenterType}
*
* @return list<Center>
*/
public function getPickedCenters(array $data): array
{
if (!array_key_exists('centers', $data)) {
throw new \RuntimeException('array has not the expected shape');
}
$centers = $data['centers'] instanceof Collection ? $data['centers']->toArray() : $data['centers'];
$regroupments = ($data['regroupments'] ?? []) instanceof Collection ? $data['regroupments']->toArray() : ($data['regroupments'] ?? []);
return $this->centerRegroupementResolver->resolveCenters($regroupments, $centers);
}
}

View File

@@ -0,0 +1,21 @@
<?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;
class ExportGenerationContext
{
public function __construct(
public readonly User $byUser,
) {}
}

View File

@@ -0,0 +1,273 @@
<?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\Center;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\Exception\UnauthorizedGenerationException;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\MainBundle\Service\Regroupement\CenterRegroupementResolver;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\QueryBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Response;
/**
* Generate a single export.
*/
final readonly class ExportGenerator
{
private bool $filterStatsByCenters;
public function __construct(
private ExportManager $exportManager,
private ExportConfigNormalizer $configNormalizer,
private LoggerInterface $logger,
private AuthorizationHelperInterface $authorizationHelper,
private CenterRegroupementResolver $centerRegroupementResolver,
private ExportConfigProcessor $exportConfigProcessor,
ParameterBagInterface $parameterBag,
private CenterRepositoryInterface $centerRepository,
) {
$this->filterStatsByCenters = $parameterBag->get('chill_main')['acl']['filter_stats_by_center'];
}
public function generate(string $exportAlias, array $configuration, ?User $byUser = null): FormattedExportGeneration
{
$data = $this->configNormalizer->denormalizeConfig($exportAlias, $configuration);
$export = $this->exportManager->getExport($exportAlias);
$centers = $this->filterCenters($byUser, $data['centers']['centers'], $data['centers']['regroupments'], $export);
$context = new ExportGenerationContext($byUser);
if ($export instanceof DirectExportInterface) {
$generatedExport = $export->generate(
$this->buildCenterReachableScopes($centers),
$data['export'],
$context,
);
if ($generatedExport instanceof Response) {
trigger_deprecation('chill-project/chill-bundles', '3.10', 'DirectExportInterface should not return a %s instance, but a %s instance', Response::class, FormattedExportGeneration::class);
return new FormattedExportGeneration($generatedExport->getContent(), $generatedExport->headers->get('Content-Type'));
}
return $generatedExport;
}
$query = $export->initiateQuery(
$this->retrieveUsedModifiers($data),
$this->buildCenterReachableScopes($centers),
$data['export'],
$context,
);
if ($query instanceof \Doctrine\ORM\NativeQuery) {
// throw an error if the export require other modifier, which is
// not allowed when the export return a `NativeQuery`
if (\count($export->supportsModifiers()) > 0) {
throw new \LogicException("The export with alias `{$exportAlias}` return ".'a `\Doctrine\ORM\NativeQuery` and supports modifiers, which is not allowed. Either the method `supportsModifiers` should return an empty array, or return a `Doctrine\ORM\QueryBuilder`');
}
} elseif ($query instanceof QueryBuilder) {
// handle filters
$this->handleFilters($query, $data[ExportType::FILTER_KEY], $context);
// handle aggregators
$this->handleAggregators($query, $data[ExportType::AGGREGATOR_KEY], $context);
$this->logger->notice('[export] will execute this qb in export', [
'dql' => $query->getDQL(),
]);
$this->logger->debug('[export] will execute this sql qb in export', [
'sql' => $query->getQuery()->getSQL(),
]);
} else {
throw new \UnexpectedValueException('The method `intiateQuery` should return a `\Doctrine\ORM\NativeQuery` or a `Doctrine\ORM\QueryBuilder` object.');
}
$result = $export->getResult($query, $data['export'], $context);
$formatter = $this->exportManager->getFormatter($data['pick_formatter']);
$filtersData = [];
$aggregatorsData = [];
if ($query instanceof QueryBuilder) {
foreach ($this->exportConfigProcessor->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]) as $alias => $aggregator) {
$aggregatorsData[$alias] = $data[ExportType::AGGREGATOR_KEY][$alias]['form'];
}
foreach ($this->exportConfigProcessor->retrieveUsedFilters($data[ExportType::FILTER_KEY]) as $alias => $filter) {
$filtersData[$alias] = $data[ExportType::FILTER_KEY][$alias]['form'];
}
}
/* @phpstan-ignore-next-line the method "generate" is not yet implemented on all formatters */
if (method_exists($formatter, 'generate')) {
return $formatter->generate(
$result,
$data['formatter'],
$exportAlias,
$data['export'],
$filtersData,
$aggregatorsData,
$context,
);
}
trigger_deprecation('chill-project/chill-bundles', '3.10', '%s should implements the "generate" method', FormatterInterface::class);
/* @phpstan-ignore-next-line this is a deprecated method that we must still call */
$generatedExport = $formatter->getResponse(
$result,
$data['formatter'],
$exportAlias,
$data['export'],
$filtersData,
$aggregatorsData,
$context,
);
return new FormattedExportGeneration($generatedExport->getContent(), $generatedExport->headers->get('content-type'));
}
private function filterCenters(User $byUser, array $centers, array $regroupements, ExportInterface|DirectExportInterface $export): array
{
if (!$this->filterStatsByCenters) {
return $this->centerRepository->findActive();
}
$authorizedCenters = new ArrayCollection($this->authorizationHelper->getReachableCenters($byUser, $export->requiredRole()));
if ($authorizedCenters->isEmpty()) {
throw new UnauthorizedGenerationException('No authorized centers');
}
$wantedCenters = $this->centerRegroupementResolver->resolveCenters($regroupements, $centers);
$resolvedCenters = [];
foreach ($wantedCenters as $wantedCenter) {
if ($authorizedCenters->contains($wantedCenter)) {
$resolvedCenters[] = $wantedCenter;
}
}
if ([] == $resolvedCenters) {
throw new UnauthorizedGenerationException('No common centers between wanted centers and authorized centers');
}
return $resolvedCenters;
}
/**
* parse the data to retrieve the used filters and aggregators.
*
* @return list<string>
*/
private function retrieveUsedModifiers(mixed $data): array
{
if (null === $data) {
return [];
}
$usedTypes = array_merge(
$this->retrieveUsedFiltersType($data[ExportType::FILTER_KEY]),
$this->retrieveUsedAggregatorsType($data[ExportType::AGGREGATOR_KEY]),
);
return array_values(array_unique($usedTypes));
}
/**
* Retrieve the filter used in this export.
*
* @return list<string> an array with types
*/
private function retrieveUsedFiltersType(mixed $data): array
{
if (null === $data) {
return [];
}
$usedTypes = [];
foreach ($this->exportConfigProcessor->retrieveUsedFilters($data) as $filter) {
if (!\in_array($filter->applyOn(), $usedTypes, true)) {
$usedTypes[] = $filter->applyOn();
}
}
return $usedTypes;
}
/**
* @return string[]
*/
private function retrieveUsedAggregatorsType(mixed $data): array
{
if (null === $data) {
return [];
}
$usedTypes = [];
foreach ($this->exportConfigProcessor->retrieveUsedAggregators($data) as $alias => $aggregator) {
if (!\in_array($aggregator->applyOn(), $usedTypes, true)) {
$usedTypes[] = $aggregator->applyOn();
}
}
return $usedTypes;
}
/**
* Alter the query with selected aggregators.
*/
private function handleAggregators(
QueryBuilder $qb,
array $data,
ExportGenerationContext $context,
): void {
foreach ($this->exportConfigProcessor->retrieveUsedAggregators($data) as $alias => $aggregator) {
$formData = $data[$alias];
$aggregator->alterQuery($qb, $formData['form'], $context);
}
}
/**
* alter the query with selected filters.
*/
private function handleFilters(
QueryBuilder $qb,
mixed $data,
ExportGenerationContext $context,
): void {
foreach ($this->exportConfigProcessor->retrieveUsedFilters($data) as $alias => $filter) {
$formData = $data[$alias];
$filter->alterQuery($qb, $formData['form'], $context);
}
}
/**
* build the array required for defining centers and circles in the initiate
* queries of ExportElementsInterfaces.
*
* @param list<Center> $centers
*/
private function buildCenterReachableScopes(array $centers)
{
return array_map(static fn (Center $center) => ['center' => $center, 'circles' => []], $centers);
}
}

View File

@@ -14,6 +14,7 @@ namespace Chill\MainBundle\Export;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* Interface for Export.
@@ -27,6 +28,7 @@ use Symfony\Component\Form\FormBuilderInterface;
* @example Chill\PersonBundle\Export\CountPerson an example of implementation
*
* @template Q of QueryBuilder|NativeQuery
* @template D of array
*/
interface ExportInterface extends ExportElementInterface
{
@@ -94,12 +96,12 @@ interface ExportInterface extends ExportElementInterface
* which do not need to be translated, or value already translated in
* database. But the header must be, in every case, translated.
*
* @param string $key The column key, as added in the query
* @param mixed[] $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
* @param string $key The column key, as added in the query
* @param list<mixed> $values The values from the result. if there are duplicates, those might be given twice. Example: array('FR', 'BE', 'CZ', 'FR', 'BE', 'FR')
*
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
* @return (callable(string|int|float|'_header'|null $value): string|int|\DateTimeInterface|TranslatableInterface) where the first argument is the value, and the function should return the label to show in the formatted file. Example : `function($countryCode) use ($countries) { return $countries[$countryCode]->getName(); }`
*/
public function getLabels($key, array $values, mixed $data);
public function getLabels(string $key, array $values, mixed $data);
/**
* give the list of keys the current export added to the queryBuilder in
@@ -108,29 +110,27 @@ interface ExportInterface extends ExportElementInterface
* Example: if your query builder will contains `SELECT count(id) AS count_id ...`,
* this function will return `array('count_id')`.
*
* @param mixed[] $data the data from the export's form (added by self::buildForm)
* @param D $data the data from the export's form (added by self::buildForm)
*/
public function getQueryKeys($data);
public function getQueryKeys(array $data): array;
/**
* Return the results of the query builder.
*
* @param Q $query
* @param mixed[] $data the data from the export's fomr (added by self::buildForm)
* @param Q $query
* @param D $data the data from the export's fomr (added by self::buildForm)
*
* @return mixed[] an array of results
*/
public function getResult($query, $data);
public function getResult(QueryBuilder|NativeQuery $query, array $data, ExportGenerationContext $context): array;
/**
* Return the Export's type. This will inform _on what_ export will apply.
* Most of the type, it will be a string which references an entity.
*
* Example of types : Chill\PersonBundle\Export\Declarations::PERSON_TYPE
*
* @return string
*/
public function getType();
public function getType(): string;
/**
* The initial query, which will be modified by ModifiersInterface
@@ -147,7 +147,21 @@ interface ExportInterface extends ExportElementInterface
*
* @return Q the query to execute
*/
public function initiateQuery(array $requiredModifiers, array $acl, array $data = []);
public function initiateQuery(array $requiredModifiers, array $acl, array $data, ExportGenerationContext $context): QueryBuilder|NativeQuery;
/**
* @param D $formData
*/
public function normalizeFormData(array $formData): array;
/**
* @param array $formData the normalized data
*
* @return D
*/
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
/**
* Return the required Role to execute the Export.

View File

@@ -11,13 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Doctrine\ORM\QueryBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -63,14 +59,13 @@ class ExportManager
iterable $exports,
iterable $aggregators,
iterable $filters,
// iterable $formatters,
iterable $formatters,
// iterable $exportElementProvider
) {
$this->exports = iterator_to_array($exports);
$this->aggregators = iterator_to_array($aggregators);
$this->filters = iterator_to_array($filters);
// NOTE: PHP crashes on the next line (exit error code 11). This is desactivated until further investigation
// $this->formatters = iterator_to_array($formatters);
$this->formatters = iterator_to_array($formatters);
// foreach ($exportElementProvider as $prefix => $provider) {
// $this->addExportElementsProvider($provider, $prefix);
@@ -102,7 +97,7 @@ class ExportManager
\in_array($filter->applyOn(), $export->supportsModifiers(), true)
&& $this->isGrantedForElement($filter, $export, $centers)
) {
$filters[$alias] = $filter;
$filters[$alias] = $this->getFilter($alias);
}
}
@@ -136,25 +131,6 @@ class ExportManager
return $aggregators;
}
public function addExportElementsProvider(ExportElementsProviderInterface $provider, string $prefix): void
{
foreach ($provider->getExportElements() as $suffix => $element) {
$alias = $prefix.'_'.$suffix;
if ($element instanceof ExportInterface) {
$this->exports[$alias] = $element;
} elseif ($element instanceof FilterInterface) {
$this->filters[$alias] = $element;
} elseif ($element instanceof AggregatorInterface) {
$this->aggregators[$alias] = $element;
} elseif ($element instanceof FormatterInterface) {
$this->addFormatter($element, $alias);
} else {
throw new \LogicException('This element '.$element::class.' is not an instance of export element');
}
}
}
/**
* add a formatter.
*
@@ -165,96 +141,22 @@ class ExportManager
$this->formatters[$alias] = $formatter;
}
/**
* Generate a response which contains the requested data.
*/
public function generate(string $exportAlias, array $pickedCentersData, array $data, array $formatterData): Response
{
$export = $this->getExport($exportAlias);
$centers = $this->getPickedCenters($pickedCentersData);
if ($export instanceof DirectExportInterface) {
return $export->generate(
$this->buildCenterReachableScopes($centers, $export),
$data[ExportType::EXPORT_KEY]
);
}
$query = $export->initiateQuery(
$this->retrieveUsedModifiers($data),
$this->buildCenterReachableScopes($centers, $export),
$data[ExportType::EXPORT_KEY]
);
if ($query instanceof \Doctrine\ORM\NativeQuery) {
// throw an error if the export require other modifier, which is
// not allowed when the export return a `NativeQuery`
if (\count($export->supportsModifiers()) > 0) {
throw new \LogicException("The export with alias `{$exportAlias}` return ".'a `\Doctrine\ORM\NativeQuery` and supports modifiers, which is not allowed. Either the method `supportsModifiers` should return an empty array, or return a `Doctrine\ORM\QueryBuilder`');
}
} elseif ($query instanceof QueryBuilder) {
// handle filters
$this->handleFilters($export, $query, $data[ExportType::FILTER_KEY], $centers);
// handle aggregators
$this->handleAggregators($export, $query, $data[ExportType::AGGREGATOR_KEY], $centers);
$this->logger->notice('[export] will execute this qb in export', [
'dql' => $query->getDQL(),
]);
} else {
throw new \UnexpectedValueException('The method `intiateQuery` should return a `\Doctrine\ORM\NativeQuery` or a `Doctrine\ORM\QueryBuilder` object.');
}
$result = $export->getResult($query, $data[ExportType::EXPORT_KEY]);
if (!is_iterable($result)) {
throw new \UnexpectedValueException(sprintf('The result of the export should be an iterable, %s given', \gettype($result)));
}
/** @var FormatterInterface $formatter */
$formatter = $this->getFormatter($this->getFormatterAlias($data));
$filtersData = [];
$aggregatorsData = [];
if ($query instanceof QueryBuilder) {
$aggregators = $this->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]);
foreach ($aggregators as $alias => $aggregator) {
$aggregatorsData[$alias] = $data[ExportType::AGGREGATOR_KEY][$alias]['form'];
}
}
$filters = $this->retrieveUsedFilters($data[ExportType::FILTER_KEY]);
foreach ($filters as $alias => $filter) {
$filtersData[$alias] = $data[ExportType::FILTER_KEY][$alias]['form'];
}
return $formatter->getResponse(
$result,
$formatterData,
$exportAlias,
$data[ExportType::EXPORT_KEY],
$filtersData,
$aggregatorsData
);
}
/**
* @param string $alias
*
* @return AggregatorInterface
*
* @throws \RuntimeException if the aggregator is not known
*/
public function getAggregator($alias)
public function getAggregator($alias): AggregatorInterface
{
if (!\array_key_exists($alias, $this->aggregators)) {
if (null === $aggregator = $this->aggregators[$alias] ?? null) {
throw new \RuntimeException("The aggregator with alias {$alias} is not known.");
}
return $this->aggregators[$alias];
if ($aggregator instanceof ExportManagerAwareInterface) {
$aggregator->setExportManager($this);
}
return $aggregator;
}
/**
@@ -313,10 +215,10 @@ class ExportManager
foreach ($this->exports as $alias => $export) {
if ($whereUserIsGranted) {
if ($this->isGrantedForElement($export, null, null)) {
yield $alias => $export;
yield $alias => $this->getExport($alias);
}
} else {
yield $alias => $export;
yield $alias => $this->getExport($alias);
}
}
}
@@ -332,9 +234,9 @@ class ExportManager
foreach ($this->getExports($whereUserIsGranted) as $alias => $export) {
if ($export instanceof GroupedExportInterface) {
$groups[$export->getGroup()][$alias] = $export;
$groups[$export->getGroup()][$alias] = $this->getExport($alias);
} else {
$groups['_'][$alias] = $export;
$groups['_'][$alias] = $this->getExport($alias);
}
}
@@ -346,11 +248,25 @@ class ExportManager
*/
public function getFilter(string $alias): FilterInterface
{
if (!\array_key_exists($alias, $this->filters)) {
if (null === $filter = $this->filters[$alias] ?? null) {
throw new \RuntimeException("The filter with alias {$alias} is not known.");
}
return $this->filters[$alias];
if ($filter instanceof ExportManagerAwareInterface) {
$filter->setExportManager($this);
}
return $filter;
}
public function hasFilter(string $alias): bool
{
return array_key_exists($alias, $this->filters);
}
public function hasAggregator(string $alias): bool
{
return array_key_exists($alias, $this->aggregators);
}
public function getAllFilters(): array
@@ -358,7 +274,7 @@ class ExportManager
$filters = [];
foreach ($this->filters as $alias => $filter) {
$filters[$alias] = $filter;
$filters[$alias] = $this->getFilter($alias);
}
return $filters;
@@ -380,11 +296,15 @@ class ExportManager
public function getFormatter(string $alias): FormatterInterface
{
if (!\array_key_exists($alias, $this->formatters)) {
if (null === $formatter = $this->formatters[$alias] ?? null) {
throw new \RuntimeException("The formatter with alias {$alias} is not known.");
}
return $this->formatters[$alias];
if ($formatter instanceof ExportManagerAwareInterface) {
$formatter->setExportManager($this);
}
return $formatter;
}
/**
@@ -412,26 +332,13 @@ class ExportManager
{
foreach ($this->formatters as $alias => $formatter) {
if (\in_array($formatter->getType(), $types, true)) {
yield $alias => $formatter;
yield $alias => $this->getFormatter($alias);
}
}
}
/**
* Get the Center picked by the user for this export. The data are
* extracted from the PickCenterType data.
*
* @param array $data the data from a PickCenterType
*
* @return \Chill\MainBundle\Entity\Center[] the picked center
*/
public function getPickedCenters(array $data): array
{
return $data;
}
/**
* get the aggregators typse used in the form export data.
* get the aggregators types used in the form export data.
*
* @param array $data the data from the export form
*
@@ -439,9 +346,15 @@ class ExportManager
*/
public function getUsedAggregatorsAliases(array $data): array
{
$aggregators = $this->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]);
$keys = [];
return array_keys(iterator_to_array($aggregators));
foreach ($data as $alias => $aggregatorData) {
if (true === $aggregatorData['enabled']) {
$keys[] = $alias;
}
}
return array_values(array_unique($keys));
}
/**
@@ -490,190 +403,4 @@ class ExportManager
return true;
}
/**
* build the array required for defining centers and circles in the initiate
* queries of ExportElementsInterfaces.
*
* @param \Chill\MainBundle\Entity\Center[] $centers
*/
private function buildCenterReachableScopes(array $centers, ExportElementInterface $element)
{
$r = [];
$user = $this->tokenStorage->getToken()->getUser();
if (!$user instanceof User) {
return [];
}
foreach ($centers as $center) {
$r[] = [
'center' => $center,
'circles' => $this->authorizationHelper->getReachableScopes(
$user,
$element->requiredRole(),
$center
),
];
}
return $r;
}
/**
* Alter the query with selected aggregators.
*
* Check for acl. If an user is not authorized to see an aggregator, throw an
* UnauthorizedException.
*
* @throw UnauthorizedHttpException if the user is not authorized
*/
private function handleAggregators(
ExportInterface $export,
QueryBuilder $qb,
array $data,
array $center,
) {
$aggregators = $this->retrieveUsedAggregators($data);
foreach ($aggregators as $alias => $aggregator) {
if (false === $this->isGrantedForElement($aggregator, $export, $center)) {
throw new UnauthorizedHttpException('You are not authorized to use the aggregator'.$aggregator->getTitle());
}
$formData = $data[$alias];
$aggregator->alterQuery($qb, $formData['form']);
}
}
/**
* alter the query with selected filters.
*
* This function check the acl.
*
* @param \Chill\MainBundle\Entity\Center[] $centers the picked centers
*
* @throw UnauthorizedHttpException if the user is not authorized
*/
private function handleFilters(
ExportInterface $export,
QueryBuilder $qb,
mixed $data,
array $centers,
) {
$filters = $this->retrieveUsedFilters($data);
foreach ($filters as $alias => $filter) {
if (false === $this->isGrantedForElement($filter, $export, $centers)) {
throw new UnauthorizedHttpException('You are not authorized to use the filter '.$filter->getTitle());
}
$formData = $data[$alias];
$this->logger->debug('alter query by filter '.$alias, [
'class' => self::class, 'function' => __FUNCTION__,
]);
$filter->alterQuery($qb, $formData['form']);
}
}
/**
* @return iterable<string, AggregatorInterface>
*/
private function retrieveUsedAggregators(mixed $data): iterable
{
if (null === $data) {
return [];
}
foreach ($data as $alias => $aggregatorData) {
if (true === $aggregatorData['enabled']) {
yield $alias => $this->getAggregator($alias);
}
}
}
/**
* @return string[]
*/
private function retrieveUsedAggregatorsType(mixed $data)
{
if (null === $data) {
return [];
}
$usedTypes = [];
foreach ($this->retrieveUsedAggregators($data) as $alias => $aggregator) {
if (!\in_array($aggregator->applyOn(), $usedTypes, true)) {
$usedTypes[] = $aggregator->applyOn();
}
}
return $usedTypes;
}
private function retrieveUsedFilters(mixed $data): iterable
{
if (null === $data) {
return [];
}
foreach ($data as $alias => $filterData) {
if (true === $filterData['enabled']) {
yield $alias => $this->getFilter($alias);
}
}
}
/**
* Retrieve the filter used in this export.
*
* @return array an array with types
*/
private function retrieveUsedFiltersType(mixed $data): iterable
{
if (null === $data) {
return [];
}
$usedTypes = [];
foreach ($data as $alias => $filterData) {
if (true === $filterData['enabled']) {
$filter = $this->getFilter($alias);
if (!\in_array($filter->applyOn(), $usedTypes, true)) {
$usedTypes[] = $filter->applyOn();
}
}
}
return $usedTypes;
}
/**
* parse the data to retrieve the used filters and aggregators.
*
* @return string[]
*/
private function retrieveUsedModifiers(mixed $data)
{
if (null === $data) {
return [];
}
$usedTypes = array_merge(
$this->retrieveUsedFiltersType($data[ExportType::FILTER_KEY]),
$this->retrieveUsedAggregatorsType($data[ExportType::AGGREGATOR_KEY])
);
$this->logger->debug(
'Required types are '.implode(', ', $usedTypes),
['class' => self::class, 'function' => __FUNCTION__]
);
return array_unique($usedTypes);
}
}

View File

@@ -0,0 +1,20 @@
<?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;
/**
* Interface which is aware of the export manager.
*/
interface ExportManagerAwareInterface
{
public function setExportManager(ExportManager $exportManager): void;
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* Interface for filters.
@@ -20,6 +21,8 @@ use Symfony\Component\Form\FormBuilderInterface;
* it will add a `WHERE` clause on this query.
*
* Filters should not add column in `SELECT` clause.
*
* @template D of array
*/
interface FilterInterface extends ModifierInterface
{
@@ -28,16 +31,30 @@ interface FilterInterface extends ModifierInterface
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
public function buildForm(FormBuilderInterface $builder): void;
/**
* Get the default data, that can be use as "data" for the form.
*
* In case of adding new parameters to a filter, you can implement a @see{DataTransformerFilterInterface} to
* transforme the filters's data saved in an export to the desired state.
*
* @return D
*/
public function getFormDefaultData(): array;
/**
* @param D $formData
*/
public function normalizeFormData(array $formData): array;
/**
* @return D
*/
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
/**
* Describe the filtering action.
*
@@ -52,7 +69,7 @@ interface FilterInterface extends ModifierInterface
* supported, later some 'html' will be added. The filter should always
* implement the 'string' format and fallback to it if other format are used.
*
* If no i18n is necessery, or if the filter translate the string by himself,
* If no i18n is necessary, or if the filter translate the string by himself,
* this function should return a string. If the filter does not do any translation,
* the return parameter should be an array, where
*
@@ -63,10 +80,9 @@ interface FilterInterface extends ModifierInterface
*
* Example: `array('my string with %parameter%', ['%parameter%' => 'good news'], 'mydomain', 'mylocale')`
*
* @param array $data
* @param string $format the format
* @param D $data
*
* @return array|string a string with the data or, if translatable, an array where first element is string, second elements is an array of arguments
* @return array|string|TranslatableInterface a string with the data or, if translatable, an array where first element is string, second elements is an array of arguments
*/
public function describeAction($data, $format = 'string');
public function describeAction(array $data, ExportGenerationContext $context): array|string|TranslatableInterface;
}

View File

@@ -0,0 +1,20 @@
<?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;
final readonly class FormattedExportGeneration
{
public function __construct(
public string $content,
public string $contentType,
) {}
}

View File

@@ -1,440 +0,0 @@
<?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\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\FormatterInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Command to get the report with curl:
* curl --user "center a_social:password" "http://localhost:8000/fr/exports/generate/count_person?export[filters][person_gender_filter][enabled]=&export[filters][person_nationality_filter][enabled]=&export[filters][person_nationality_filter][form][nationalities]=&export[aggregators][person_nationality_aggregator][order]=1&export[aggregators][person_nationality_aggregator][form][group_by_level]=country&export[submit]=&export[_token]=RHpjHl389GrK-bd6iY5NsEqrD5UKOTHH40QKE9J1edU" --globoff.
*
* @deprecated this formatter is not used any more
*/
class CSVFormatter implements FormatterInterface
{
protected $aggregators;
protected $aggregatorsData;
protected $export;
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
protected $filtersData;
protected $formatterData;
protected $labels;
protected $result;
public function __construct(
protected TranslatorInterface $translator,
ExportManager $manager,
) {
$this->exportManager = $manager;
}
/**
* @uses appendAggregatorForm
*/
public function buildForm(FormBuilderInterface $builder, $exportAlias, array $aggregatorAliases)
{
$aggregators = $this->exportManager->getAggregators($aggregatorAliases);
$nb = \count($aggregatorAliases);
foreach ($aggregators as $alias => $aggregator) {
$builderAggregator = $builder->create($alias, FormType::class, [
'label' => $aggregator->getTitle(),
'block_name' => '_aggregator_placement_csv_formatter',
]);
$this->appendAggregatorForm($builderAggregator, $nb);
$builder->add($builderAggregator);
}
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return [];
}
public function gatherFiltersDescriptions()
{
$descriptions = [];
foreach ($this->filtersData as $key => $filterData) {
$statement = $this->exportManager
->getFilter($key)
->describeAction($filterData);
if (null === $statement) {
continue;
}
if (\is_array($statement)) {
$descriptions[] = $this->translator->trans(
$statement[0],
$statement[1],
$statement[2] ?? null,
$statement[3] ?? null
);
} else {
$descriptions[] = $statement;
}
}
return $descriptions;
}
public function getName()
{
return 'Comma separated values (CSV)';
}
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
) {
$this->result = $result;
$this->orderingHeaders($formatterData);
$this->export = $this->exportManager->getExport($exportAlias);
$this->aggregators = iterator_to_array($this->exportManager
->getAggregators(array_keys($aggregatorsData)));
$this->exportData = $exportData;
$this->aggregatorsData = $aggregatorsData;
$this->labels = $this->gatherLabels();
$this->filtersData = $filtersData;
$response = new Response();
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
// $response->headers->set('Content-Disposition','attachment; filename="export.csv"');
$response->setContent($this->generateContent());
return $response;
}
public function getType()
{
return 'tabular';
}
protected function gatherLabels()
{
return array_merge(
$this->gatherLabelsFromAggregators(),
$this->gatherLabelsFromExport()
);
}
protected function gatherLabelsFromAggregators()
{
$labels = [];
/* @var $aggretator \Chill\MainBundle\Export\AggregatorInterface */
foreach ($this->aggregators as $alias => $aggregator) {
$keys = $aggregator->getQueryKeys($this->aggregatorsData[$alias]);
// gather data in an array
foreach ($keys as $key) {
$values = array_map(static function ($row) use ($key, $alias) {
if (!\array_key_exists($key, $row)) {
throw new \LogicException("the key '".$key."' is declared by the aggregator with alias '".$alias."' but is not ".'present in results');
}
return $row[$key];
}, $this->result);
$labels[$key] = $aggregator->getLabels(
$key,
array_unique($values),
$this->aggregatorsData[$alias]
);
}
}
return $labels;
}
protected function gatherLabelsFromExport()
{
$labels = [];
$export = $this->export;
$keys = $this->export->getQueryKeys($this->exportData);
foreach ($keys as $key) {
$values = array_map(static function ($row) use ($key, $export) {
if (!\array_key_exists($key, $row)) {
throw new \LogicException("the key '".$key."' is declared by the export with title '".$export->getTitle()."' but is not ".'present in results');
}
return $row[$key];
}, $this->result);
$labels[$key] = $this->export->getLabels(
$key,
array_unique($values),
$this->exportData
);
}
return $labels;
}
protected function generateContent()
{
$line = null;
$rowKeysNb = \count($this->getRowHeaders());
$columnKeysNb = \count($this->getColumnHeaders());
$resultsKeysNb = \count($this->export->getQueryKeys($this->exportData));
$results = $this->getOrderedResults();
/** @var string[] $columnHeaders the column headers associations */
$columnHeaders = [];
/** @var string[] $data the data of the csv file */
$contentData = [];
$content = [];
// create a file pointer connected to the output stream
$output = fopen('php://output', 'wb');
// title
fputcsv($output, [$this->translator->trans($this->export->getTitle())]);
// blank line
fputcsv($output, ['']);
// add filtering description
foreach ($this->gatherFiltersDescriptions() as $desc) {
fputcsv($output, [$desc]);
}
// blank line
fputcsv($output, ['']);
// iterate on result to : 1. populate row headers, 2. populate column headers, 3. add result
foreach ($results as $row) {
$rowHeaders = \array_slice($row, 0, $rowKeysNb);
// first line : we create line and adding them row headers
if (!isset($line)) {
$line = \array_slice($row, 0, $rowKeysNb);
}
// do we have to create a new line ? if the rows are equals, continue on the line, else create a next line
if (\array_slice($line, 0, $rowKeysNb) !== $rowHeaders) {
$contentData[] = $line;
$line = \array_slice($row, 0, $rowKeysNb);
}
// add the column headers
/** @var string[] $columns the column for this row */
$columns = \array_slice($row, $rowKeysNb, $columnKeysNb);
$columnPosition = $this->findColumnPosition($columnHeaders, $columns);
// fill with blank at the position given by the columnPosition + nbRowHeaders
for ($i = 0; $i < $columnPosition; ++$i) {
if (!isset($line[$rowKeysNb + $i])) {
$line[$rowKeysNb + $i] = '';
}
}
$resultData = \array_slice($row, $resultsKeysNb * -1);
foreach ($resultData as $data) {
$line[] = $data;
}
}
// we add the last line
$contentData[] = $line;
// column title headers
for ($i = 0; $i < $columnKeysNb; ++$i) {
$line = array_fill(0, $rowKeysNb, '');
foreach ($columnHeaders as $set) {
$line[] = $set[$i];
}
$content[] = $line;
}
// row title headers
$headerLine = [];
foreach ($this->getRowHeaders() as $headerKey) {
$headerLine[] = \array_key_exists('_header', $this->labels[$headerKey]) ?
$this->labels[$headerKey]['_header'] : '';
}
foreach ($this->export->getQueryKeys($this->exportData) as $key) {
$headerLine[] = \array_key_exists('_header', $this->labels[$key]) ?
$this->labels[$key]['_header'] : '';
}
fputcsv($output, $headerLine);
unset($headerLine); // free memory
// generate CSV
foreach ($content as $line) {
fputcsv($output, $line);
}
foreach ($contentData as $line) {
fputcsv($output, $line);
}
$text = stream_get_contents($output);
fclose($output);
return $text;
}
protected function getColumnHeaders()
{
return $this->getPositionnalHeaders('c');
}
protected function getRowHeaders()
{
return $this->getPositionnalHeaders('r');
}
/**
* ordering aggregators, preserving key association.
*
* This function do not mind about position.
*
* If two aggregators have the same order, the second given will be placed
* after. This is not significant for the first ordering.
*/
protected function orderingHeaders(array $formatterData)
{
$this->formatterData = $formatterData;
uasort(
$this->formatterData,
static fn (array $a, array $b): int => ($a['order'] <= $b['order'] ? -1 : 1)
);
}
/**
* append a form line by aggregator on the formatter form.
*
* This form allow to choose the aggregator position (row or column) and
* the ordering
*
* @param string $nbAggregators
*/
private function appendAggregatorForm(FormBuilderInterface $builder, $nbAggregators)
{
$builder->add('order', ChoiceType::class, [
'choices' => array_combine(
range(1, $nbAggregators),
range(1, $nbAggregators)
),
'multiple' => false,
'expanded' => false,
]);
$builder->add('position', ChoiceType::class, [
'choices' => [
'row' => 'r',
'column' => 'c',
],
'multiple' => false,
'expanded' => false,
]);
}
private function findColumnPosition(&$columnHeaders, $columnToFind): int
{
$i = 0;
foreach ($columnHeaders as $set) {
if ($set === $columnToFind) {
return $i;
}
++$i;
}
// we didn't find it, adding the column
$columnHeaders[] = $columnToFind;
return $i++;
}
private function getOrderedResults()
{
$r = [];
$results = $this->result;
$labels = $this->labels;
$rowKeys = $this->getRowHeaders();
$columnKeys = $this->getColumnHeaders();
$resultsKeys = $this->export->getQueryKeys($this->exportData);
$headers = array_merge($rowKeys, $columnKeys);
foreach ($results as $row) {
$line = [];
foreach ($headers as $key) {
$line[] = \call_user_func($labels[$key], $row[$key]);
}
// append result
foreach ($resultsKeys as $key) {
$line[] = \call_user_func($labels[$key], $row[$key]);
}
$r[] = $line;
}
array_multisort($r);
return $r;
}
/**
* @param string $position may be 'c' (column) or 'r' (row)
*
* @return string[]
*
* @throws \RuntimeException
*/
private function getPositionnalHeaders($position)
{
$headers = [];
foreach ($this->formatterData as $alias => $data) {
if (!\array_key_exists($alias, $this->aggregatorsData)) {
throw new \RuntimeException('the formatter wants to use the '."aggregator with alias {$alias}, but the export do not ".'contains data about it');
}
$aggregator = $this->aggregators[$alias];
if ($data['position'] === $position) {
$headers = array_merge($headers, $aggregator->getQueryKeys($this->aggregatorsData[$alias]));
}
}
return $headers;
}
}

View File

@@ -1,223 +0,0 @@
<?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\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\FormatterInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
// command to get the report with curl : curl --user "center a_social:password" "http://localhost:8000/fr/exports/generate/count_person?export[filters][person_gender_filter][enabled]=&export[filters][person_nationality_filter][enabled]=&export[filters][person_nationality_filter][form][nationalities]=&export[aggregators][person_nationality_aggregator][order]=1&export[aggregators][person_nationality_aggregator][form][group_by_level]=country&export[submit]=&export[_token]=RHpjHl389GrK-bd6iY5NsEqrD5UKOTHH40QKE9J1edU" --globoff
/**
* Create a CSV List for the export.
*/
class CSVListFormatter implements FormatterInterface
{
protected $exportAlias;
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
protected $formatterData;
/**
* This variable cache the labels internally.
*
* @var string[]
*/
protected $labelsCache;
protected $result;
/**
* @var TranslatorInterface
*/
protected $translator;
public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
{
$this->translator = $translatorInterface;
$this->exportManager = $exportManager;
}
/**
* build a form, which will be used to collect data required for the execution
* of this formatter.
*
* @uses appendAggregatorForm
*
* @param type $exportAlias
*/
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
array $aggregatorAliases,
) {
$builder->add('numerotation', ChoiceType::class, [
'choices' => [
'yes' => true,
'no' => false,
],
'expanded' => true,
'multiple' => false,
'label' => 'Add a number on first column',
]);
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return ['numerotation' => true];
}
public function getName()
{
return 'CSV vertical list';
}
/**
* Generate a response from the data collected on differents ExportElementInterface.
*
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
*
* @return Response The response to be shown
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
) {
$this->result = $result;
$this->exportAlias = $exportAlias;
$this->exportData = $exportData;
$this->formatterData = $formatterData;
$output = fopen('php://output', 'wb');
$this->prepareHeaders($output);
$i = 1;
foreach ($result as $row) {
$line = [];
if (true === $this->formatterData['numerotation']) {
$line[] = $i;
}
foreach ($row as $key => $value) {
$line[] = $this->getLabel($key, $value);
}
fputcsv($output, $line);
++$i;
}
$csvContent = stream_get_contents($output);
fclose($output);
$response = new Response();
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
// $response->headers->set('Content-Disposition','attachment; filename="export.csv"');
$response->setContent($csvContent);
return $response;
}
public function getType()
{
return FormatterInterface::TYPE_LIST;
}
/**
* Give the label corresponding to the given key and value.
*
* @param string $key
* @param string $value
*
* @return string
*
* @throws \LogicException if the label is not found
*/
protected function getLabel($key, $value)
{
if (null === $this->labelsCache) {
$this->prepareCacheLabels();
}
if (!\array_key_exists($key, $this->labelsCache)) {
throw new \OutOfBoundsException(sprintf('The key "%s" is not present in the list of keys handled by this query. Check your `getKeys` and `getLabels` methods. Available keys are %s.', $key, \implode(', ', \array_keys($this->labelsCache))));
}
return $this->labelsCache[$key]($value);
}
/**
* Prepare the label cache which will be used by getLabel. This function
* should be called only once in the generation lifecycle.
*/
protected function prepareCacheLabels()
{
$export = $this->exportManager->getExport($this->exportAlias);
$keys = $export->getQueryKeys($this->exportData);
foreach ($keys as $key) {
// get an array with all values for this key if possible
$values = \array_map(static fn ($v) => $v[$key], $this->result);
// store the label in the labelsCache property
$this->labelsCache[$key] = $export->getLabels($key, $values, $this->exportData);
}
}
/**
* add the headers to the csv file.
*
* @param resource $output
*/
protected function prepareHeaders($output)
{
$keys = $this->exportManager->getExport($this->exportAlias)->getQueryKeys($this->exportData);
// we want to keep the order of the first row. So we will iterate on the first row of the results
$first_row = \count($this->result) > 0 ? $this->result[0] : [];
$header_line = [];
if (true === $this->formatterData['numerotation']) {
$header_line[] = $this->translator->trans('Number');
}
foreach ($first_row as $key => $value) {
$header_line[] = $this->translator->trans(
$this->getLabel($key, '_header')
);
}
if (\count($header_line) > 0) {
fputcsv($output, $header_line);
}
}
}

View File

@@ -1,217 +0,0 @@
<?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\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\FormatterInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Create a CSV List for the export where the header are printed on the
* first column, and the result goes from left to right.
*/
class CSVPivotedListFormatter implements FormatterInterface
{
protected $exportAlias;
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
protected $formatterData;
/**
* This variable cache the labels internally.
*
* @var string[]
*/
protected $labelsCache;
protected $result;
/**
* @var TranslatorInterface
*/
protected $translator;
public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
{
$this->translator = $translatorInterface;
$this->exportManager = $exportManager;
}
/**
* build a form, which will be used to collect data required for the execution
* of this formatter.
*
* @uses appendAggregatorForm
*
* @param type $exportAlias
*/
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
array $aggregatorAliases,
) {
$builder->add('numerotation', ChoiceType::class, [
'choices' => [
'yes' => true,
'no' => false,
],
'expanded' => true,
'multiple' => false,
'label' => 'Add a number on first column',
'data' => true,
]);
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return ['numerotation' => true];
}
public function getName()
{
return 'CSV horizontal list';
}
/**
* Generate a response from the data collected on differents ExportElementInterface.
*
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
*
* @return Response The response to be shown
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
) {
$this->result = $result;
$this->exportAlias = $exportAlias;
$this->exportData = $exportData;
$this->formatterData = $formatterData;
$output = fopen('php://output', 'wb');
$i = 1;
$lines = [];
$this->prepareHeaders($lines);
foreach ($result as $row) {
$j = 0;
if (true === $this->formatterData['numerotation']) {
$lines[$j][] = $i;
++$j;
}
foreach ($row as $key => $value) {
$lines[$j][] = $this->getLabel($key, $value);
++$j;
}
++$i;
}
// adding the lines to the csv output
foreach ($lines as $line) {
fputcsv($output, $line);
}
$csvContent = stream_get_contents($output);
fclose($output);
$response = new Response();
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
$response->headers->set('Content-Disposition', 'attachment; filename="export.csv"');
$response->setContent($csvContent);
return $response;
}
public function getType()
{
return FormatterInterface::TYPE_LIST;
}
/**
* Give the label corresponding to the given key and value.
*
* @param string $key
* @param string $value
*
* @return string
*
* @throws \LogicException if the label is not found
*/
protected function getLabel($key, $value)
{
if (null === $this->labelsCache) {
$this->prepareCacheLabels();
}
return $this->labelsCache[$key]($value);
}
/**
* Prepare the label cache which will be used by getLabel. This function
* should be called only once in the generation lifecycle.
*/
protected function prepareCacheLabels()
{
$export = $this->exportManager->getExport($this->exportAlias);
$keys = $export->getQueryKeys($this->exportData);
foreach ($keys as $key) {
// get an array with all values for this key if possible
$values = \array_map(static fn ($v) => $v[$key], $this->result);
// store the label in the labelsCache property
$this->labelsCache[$key] = $export->getLabels($key, $values, $this->exportData);
}
}
/**
* add the headers to lines array.
*
* @param array $lines the lines where the header will be added
*/
protected function prepareHeaders(array &$lines)
{
$keys = $this->exportManager->getExport($this->exportAlias)->getQueryKeys($this->exportData);
// we want to keep the order of the first row. So we will iterate on the first row of the results
$first_row = \count($this->result) > 0 ? $this->result[0] : [];
$header_line = [];
if (true === $this->formatterData['numerotation']) {
$lines[] = [$this->translator->trans('Number')];
}
foreach ($first_row as $key => $value) {
$lines[] = [$this->getLabel($key, '_header')];
}
}
}

View File

@@ -11,126 +11,32 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\ExportGenerationContext;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManagerAwareInterface;
use Chill\MainBundle\Export\FormattedExportGeneration;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\Helper\ExportManagerAwareTrait;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class SpreadSheetFormatter implements FormatterInterface
final class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInterface
{
/**
* an array where keys are the aggregators aliases and
* values are the data.
*
* replaced when `getResponse` is called.
*
* @var type
*/
protected $aggregatorsData;
use ExportManagerAwareTrait;
/**
* The export.
*
* replaced when `getResponse` is called.
*
* @var \Chill\MainBundle\Export\ExportInterface
*/
protected $export;
/**
* replaced when `getResponse` is called.
*
* @var type
*/
// protected $aggregators;
/**
* array containing value of export form.
*
* replaced when `getResponse` is called.
*
* @var array
*/
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
/**
* replaced when `getResponse` is called.
*
* @var array
*/
protected $filtersData;
/**
* replaced when `getResponse` is called.
*
* @var type
*/
protected $formatterData;
/**
* The result, as returned by the export.
*
* replaced when `getResponse` is called.
*
* @var type
*/
protected $result;
/**
* replaced when `getResponse` is called.
*
* @var array
*/
// protected $labels;
/**
* temporary file to store spreadsheet.
*
* @var string
*/
protected $tempfile;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* cache for displayable result.
*
* This cache is reset when `getResponse` is called.
*
* The array's keys are the keys in the raw result, and
* values are the callable which will transform the raw result to
* displayable result.
*/
private ?array $cacheDisplayableResult = null;
/**
* Whethe `cacheDisplayableResult` is initialized or not.
*/
private bool $cacheDisplayableResultIsInitialized = false;
public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
{
$this->translator = $translatorInterface;
$this->exportManager = $exportManager;
}
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
array $aggregatorAliases,
) {
): void {
// choosing between formats
$builder->add('format', ChoiceType::class, [
'choices' => [
@@ -142,7 +48,7 @@ class SpreadSheetFormatter implements FormatterInterface
]);
// ordering aggregators
$aggregators = $this->exportManager->getAggregators($aggregatorAliases);
$aggregators = $this->getExportManager()->getAggregators($aggregatorAliases);
$nb = \count($aggregatorAliases);
foreach ($aggregators as $alias => $aggregator) {
@@ -155,11 +61,26 @@ class SpreadSheetFormatter implements FormatterInterface
}
}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return $formData;
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return $formData;
}
public function getFormDefaultData(array $aggregatorAliases): array
{
$data = ['format' => 'xlsx'];
$aggregators = iterator_to_array($this->exportManager->getAggregators($aggregatorAliases));
$aggregators = iterator_to_array($this->getExportManager()->getAggregators($aggregatorAliases));
foreach (array_keys($aggregators) as $index => $alias) {
$data[$alias] = ['order' => $index + 1];
}
@@ -172,6 +93,51 @@ class SpreadSheetFormatter implements FormatterInterface
return 'SpreadSheet (xlsx, ods)';
}
public function generate(
$result,
$formatterData,
string $exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
ExportGenerationContext $context,
) {
// Initialize local variables instead of class properties
/** @var ExportInterface $export */
$export = $this->getExportManager()->getExport($exportAlias);
// Initialize cache variables
$cacheDisplayableResult = $this->initializeDisplayable($result, $export, $exportData, $aggregatorsData);
$tempfile = \tempnam(\sys_get_temp_dir(), '');
if (false === $tempfile) {
throw new \RuntimeException('Unable to create temporary file');
}
$this->generateContent(
$context,
$tempfile,
$result,
$formatterData,
$export,
$exportData,
$filtersData,
$aggregatorsData,
$cacheDisplayableResult,
);
$result = new FormattedExportGeneration(
file_get_contents($tempfile),
$this->getContentType($formatterData['format']),
);
// remove the temp file from disk
\unlink($tempfile);
return $result;
}
public function getResponse(
$result,
$formatterData,
@@ -179,44 +145,22 @@ class SpreadSheetFormatter implements FormatterInterface
array $exportData,
array $filtersData,
array $aggregatorsData,
ExportGenerationContext $context,
): Response {
// store all data when the process is initiated
$this->result = $result;
$this->formatterData = $formatterData;
$this->export = $this->exportManager->getExport($exportAlias);
$this->exportData = $exportData;
$this->filtersData = $filtersData;
$this->aggregatorsData = $aggregatorsData;
$formattedResult = $this->generate($result, $formatterData, $exportAlias, $exportData, $filtersData, $aggregatorsData, $context);
// reset cache
$this->cacheDisplayableResult = [];
$this->cacheDisplayableResultIsInitialized = false;
$response = new Response();
$response->headers->set(
'Content-Type',
$this->getContentType($this->formatterData['format'])
);
$this->tempfile = \tempnam(\sys_get_temp_dir(), '');
$this->generateContent();
$f = \fopen($this->tempfile, 'rb');
$response->setContent(\stream_get_contents($f));
fclose($f);
// remove the temp file from disk
\unlink($this->tempfile);
$response = new BinaryFileResponse($formattedResult->content);
$response->headers->set('Content-Type', $formattedResult->contentType);
return $response;
}
public function getType()
public function getType(): string
{
return 'tabular';
}
protected function addContentTable(
private function addContentTable(
Worksheet $worksheet,
$sortedResults,
$line,
@@ -238,20 +182,21 @@ class SpreadSheetFormatter implements FormatterInterface
*
* @return int the line number after the last description
*/
protected function addFiltersDescription(Worksheet &$worksheet)
private function addFiltersDescription(Worksheet &$worksheet, ExportGenerationContext $context, array $filtersData)
{
$line = 3;
foreach ($this->filtersData as $alias => $data) {
$filter = $this->exportManager->getFilter($alias);
$description = $filter->describeAction($data, 'string');
foreach ($filtersData as $alias => $data) {
$filter = $this->getExportManager()->getFilter($alias);
$description = $filter->describeAction($data, $context);
if (\is_array($description)) {
$description = $this->translator
->trans(
$description[0],
$description[1] ?? []
$description[1] ?? [],
);
} elseif ($description instanceof TranslatableInterface) {
$description = $description->trans($this->translator, $this->translator->getLocale());
}
$worksheet->setCellValue('A'.$line, $description);
@@ -266,23 +211,23 @@ class SpreadSheetFormatter implements FormatterInterface
*
* return the line number where the next content (i.e. result) should
* be appended.
*
* @param int $line
*
* @return int
*/
protected function addHeaders(
private function addHeaders(
Worksheet &$worksheet,
array $globalKeys,
$line,
) {
int $line,
array $cacheDisplayableResult = [],
): int {
// get the displayable form of headers
$displayables = [];
foreach ($globalKeys as $key) {
$displayables[] = $this->translator->trans(
$this->getDisplayableResult($key, '_header')
);
$displayable = $this->getDisplayableResult($key, '_header', $cacheDisplayableResult);
if ($displayable instanceof TranslatableInterface) {
$displayables[] = $displayable->trans($this->translator, $this->translator->getLocale());
} else {
$displayables[] = $this->translator->trans($this->getDisplayableResult($key, '_header', $cacheDisplayableResult));
}
}
// add headers on worksheet
@@ -299,9 +244,9 @@ class SpreadSheetFormatter implements FormatterInterface
* Add the title to the worksheet and merge the cell containing
* the title.
*/
protected function addTitleToWorkSheet(Worksheet &$worksheet)
private function addTitleToWorkSheet(Worksheet &$worksheet, $export)
{
$worksheet->setCellValue('A1', $this->getTitle());
$worksheet->setCellValue('A1', $this->getTitle($export));
$worksheet->mergeCells('A1:G1');
}
@@ -310,14 +255,14 @@ class SpreadSheetFormatter implements FormatterInterface
*
* @return array where 1st member is spreadsheet, 2nd is worksheet
*/
protected function createSpreadsheet()
private function createSpreadsheet($export)
{
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
// setting the worksheet title and code name
$worksheet
->setTitle($this->getTitle())
->setTitle($this->getTitle($export))
->setCodeName('result');
return [$spreadsheet, $worksheet];
@@ -326,29 +271,38 @@ class SpreadSheetFormatter implements FormatterInterface
/**
* Generate the content and write it to php://temp.
*/
protected function generateContent()
{
[$spreadsheet, $worksheet] = $this->createSpreadsheet();
private function generateContent(
ExportGenerationContext $context,
string $tempfile,
$result,
$formatterData,
$export,
array $exportData,
array $filtersData,
array $aggregatorsData,
array $cacheDisplayableResult,
) {
[$spreadsheet, $worksheet] = $this->createSpreadsheet($export);
$this->addTitleToWorkSheet($worksheet);
$line = $this->addFiltersDescription($worksheet);
$this->addTitleToWorkSheet($worksheet, $export);
$line = $this->addFiltersDescription($worksheet, $context, $filtersData);
// at this point, we are going to sort retsults for an easier manipulation
// at this point, we are going to sort results for an easier manipulation
[$sortedResult, $exportKeys, $aggregatorKeys, $globalKeys] =
$this->sortResult();
$this->sortResult($result, $export, $exportData, $aggregatorsData, $formatterData, $cacheDisplayableResult);
$line = $this->addHeaders($worksheet, $globalKeys, $line);
$line = $this->addHeaders($worksheet, $globalKeys, $line, $cacheDisplayableResult);
$line = $this->addContentTable($worksheet, $sortedResult, $line);
$this->addContentTable($worksheet, $sortedResult, $line);
$writer = match ($this->formatterData['format']) {
$writer = match ($formatterData['format']) {
'ods' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Ods'),
'xlsx' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Xlsx'),
'csv' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Csv'),
default => throw new \LogicException(),
};
$writer->save($this->tempfile);
$writer->save($tempfile);
}
/**
@@ -357,7 +311,7 @@ class SpreadSheetFormatter implements FormatterInterface
*
* @return string[] an array containing the keys of aggregators
*/
protected function getAggregatorKeysSorted()
private function getAggregatorKeysSorted(array $aggregatorsData, array $formatterData)
{
// empty array for aggregators keys
$keys = [];
@@ -365,7 +319,7 @@ class SpreadSheetFormatter implements FormatterInterface
// during sorting
$aggregatorKeyAssociation = [];
foreach ($this->aggregatorsData as $alias => $data) {
foreach ($aggregatorsData as $alias => $data) {
$aggregator = $this->exportManager->getAggregator($alias);
$aggregatorsKeys = $aggregator->getQueryKeys($data);
// append the keys from aggregator to the $keys existing array
@@ -377,9 +331,9 @@ class SpreadSheetFormatter implements FormatterInterface
}
// sort the result using the form
usort($keys, function ($a, $b) use ($aggregatorKeyAssociation) {
$A = $this->formatterData[$aggregatorKeyAssociation[$a]]['order'];
$B = $this->formatterData[$aggregatorKeyAssociation[$b]]['order'];
usort($keys, function ($a, $b) use ($aggregatorKeyAssociation, $formatterData) {
$A = $formatterData[$aggregatorKeyAssociation[$a]]['order'];
$B = $formatterData[$aggregatorKeyAssociation[$b]]['order'];
if ($A === $B) {
return 0;
@@ -395,7 +349,7 @@ class SpreadSheetFormatter implements FormatterInterface
return $keys;
}
protected function getContentType($format)
private function getContentType($format)
{
switch ($format) {
case 'csv':
@@ -412,25 +366,26 @@ class SpreadSheetFormatter implements FormatterInterface
/**
* Get the displayable result.
*
* @param string $key
*
* @return string
*/
protected function getDisplayableResult($key, mixed $value)
{
if (false === $this->cacheDisplayableResultIsInitialized) {
$this->initializeCache($key);
}
private function getDisplayableResult(
string $key,
mixed $value,
array $cacheDisplayableResult,
): string|TranslatableInterface|\DateTimeInterface|int|float|bool {
$value ??= '';
return \call_user_func($this->cacheDisplayableResult[$key], $value);
return \call_user_func($cacheDisplayableResult[$key], $value);
}
protected function getTitle()
private function getTitle($export): string
{
$title = $this->translator->trans($this->export->getTitle());
$original = $export->getTitle();
if ($original instanceof TranslatableInterface) {
$title = $original->trans($this->translator, $this->translator->getLocale());
} else {
$title = $this->translator->trans($original);
}
if (30 < strlen($title)) {
return substr($title, 0, 30).'…';
@@ -439,8 +394,13 @@ class SpreadSheetFormatter implements FormatterInterface
return $title;
}
protected function initializeCache($key)
{
private function initializeDisplayable(
$result,
ExportInterface $export,
array $exportData,
array $aggregatorsData,
): array {
$cacheDisplayableResult = [];
/*
* this function follows the following steps :
*
@@ -453,13 +413,12 @@ class SpreadSheetFormatter implements FormatterInterface
// 1. create an associative array with key and export / aggregator
$keysExportElementAssociation = [];
// keys for export
foreach ($this->export->getQueryKeys($this->exportData) as $key) {
$keysExportElementAssociation[$key] = [$this->export,
$this->exportData, ];
foreach ($export->getQueryKeys($exportData) as $key) {
$keysExportElementAssociation[$key] = [$export, $exportData];
}
// keys for aggregator
foreach ($this->aggregatorsData as $alias => $data) {
$aggregator = $this->exportManager->getAggregator($alias);
foreach ($aggregatorsData as $alias => $data) {
$aggregator = $this->getExportManager()->getAggregator($alias);
foreach ($aggregator->getQueryKeys($data) as $key) {
$keysExportElementAssociation[$key] = [$aggregator, $data];
@@ -471,7 +430,7 @@ class SpreadSheetFormatter implements FormatterInterface
$allValues = [];
// store all the values in an array
foreach ($this->result as $row) {
foreach ($result as $row) {
foreach ($keys as $key) {
$allValues[$key][] = $row[$key];
}
@@ -482,15 +441,14 @@ class SpreadSheetFormatter implements FormatterInterface
foreach ($keysExportElementAssociation as $key => [$element, $data]) {
// handle the case when there is not results lines (query is empty)
if ([] === $allValues) {
$this->cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data);
$cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data);
} else {
$this->cacheDisplayableResult[$key] =
$cacheDisplayableResult[$key] =
$element->getLabels($key, \array_unique($allValues[$key]), $data);
}
}
// the cache is initialized !
$this->cacheDisplayableResultIsInitialized = true;
return $cacheDisplayableResult;
}
/**
@@ -528,23 +486,28 @@ class SpreadSheetFormatter implements FormatterInterface
* )
* ```
*/
protected function sortResult()
{
private function sortResult(
$result,
ExportInterface $export,
array $exportData,
array $aggregatorsData,
array $formatterData,
array $cacheDisplayableResult,
) {
// get the keys for each row
$exportKeys = $this->export->getQueryKeys($this->exportData);
$aggregatorKeys = $this->getAggregatorKeysSorted();
$exportKeys = $export->getQueryKeys($exportData);
$aggregatorKeys = $this->getAggregatorKeysSorted($aggregatorsData, $formatterData);
$globalKeys = \array_merge($aggregatorKeys, $exportKeys);
$sortedResult = \array_map(function ($row) use ($globalKeys) {
$sortedResult = \array_map(function ($row) use ($globalKeys, $cacheDisplayableResult) {
$newRow = [];
foreach ($globalKeys as $key) {
$newRow[] = $this->getDisplayableResult($key, $row[$key]);
$newRow[] = $this->getDisplayableResult($key, $row[$key], $cacheDisplayableResult);
}
return $newRow;
}, $this->result);
}, $result);
\array_multisort($sortedResult);

View File

@@ -11,68 +11,42 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export\Formatter;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\ExportGenerationContext;
use Chill\MainBundle\Export\ExportManagerAwareInterface;
use Chill\MainBundle\Export\FormattedExportGeneration;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\Helper\ExportManagerAwareTrait;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
// command to get the report with curl : curl --user "center a_social:password" "http://localhost:8000/fr/exports/generate/count_person?export[filters][person_gender_filter][enabled]=&export[filters][person_nationality_filter][enabled]=&export[filters][person_nationality_filter][form][nationalities]=&export[aggregators][person_nationality_aggregator][order]=1&export[aggregators][person_nationality_aggregator][form][group_by_level]=country&export[submit]=&export[_token]=RHpjHl389GrK-bd6iY5NsEqrD5UKOTHH40QKE9J1edU" --globoff
/**
* Create a CSV List for the export.
*/
class SpreadsheetListFormatter implements FormatterInterface
class SpreadsheetListFormatter implements FormatterInterface, ExportManagerAwareInterface
{
protected $exportAlias;
use ExportManagerAwareTrait;
protected $exportData;
/**
* @var ExportManager
*/
protected $exportManager;
protected $formatterData;
/**
* This variable cache the labels internally.
*
* @var string[]
*/
protected $labelsCache;
protected $result;
/**
* @var TranslatorInterface
*/
protected $translator;
public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
{
$this->translator = $translatorInterface;
$this->exportManager = $exportManager;
}
public function __construct(private readonly TranslatorInterface $translator) {}
/**
* build a form, which will be used to collect data required for the execution
* of this formatter.
*
* @uses appendAggregatorForm
*
* @param string $exportAlias
*/
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
string $exportAlias,
array $aggregatorAliases,
) {
): void {
$builder
->add('format', ChoiceType::class, [
'choices' => [
@@ -92,58 +66,52 @@ class SpreadsheetListFormatter implements FormatterInterface
]);
}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return ['format' => $formData['format'], 'numerotation' => $formData['numerotation']];
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return ['format' => $formData['format'], 'numerotation' => $formData['numerotation']];
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return ['numerotation' => true, 'format' => 'xlsx'];
}
public function getName()
public function getName(): string|TranslatableInterface
{
return 'Spreadsheet list formatter (.xlsx, .ods)';
}
/**
* Generate a response from the data collected on differents ExportElementInterface.
*
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
*
* @return Response The response to be shown
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
) {
$this->result = $result;
$this->exportAlias = $exportAlias;
$this->exportData = $exportData;
$this->formatterData = $formatterData;
public function generate($result, $formatterData, string $exportAlias, array $exportData, array $filtersData, array $aggregatorsData, ExportGenerationContext $context): FormattedExportGeneration
{
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$cacheLabels = $this->prepareCacheLabels($result, $exportAlias, $exportData);
$this->prepareHeaders($worksheet);
$this->prepareHeaders($cacheLabels, $worksheet, $result, $formatterData, $exportAlias, $exportData);
$i = 1;
foreach ($result as $row) {
if (true === $this->formatterData['numerotation']) {
if (true === $formatterData['numerotation']) {
$worksheet->setCellValue('A'.($i + 1), (string) $i);
}
$a = $this->formatterData['numerotation'] ? 'B' : 'A';
$a = $formatterData['numerotation'] ? 'B' : 'A';
foreach ($row as $key => $value) {
$row = $a.($i + 1);
$formattedValue = $this->getLabel($key, $value);
$formattedValue = $this->getLabel($cacheLabels, $key, $value, $result, $exportAlias, $exportData);
if ($formattedValue instanceof \DateTimeInterface) {
$worksheet->setCellValue($row, Date::PHPToExcel($formattedValue));
@@ -157,6 +125,8 @@ class SpreadsheetListFormatter implements FormatterInterface
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_DATE_DATETIME);
}
} elseif ($formattedValue instanceof TranslatableInterface) {
$worksheet->setCellValue($row, $formattedValue->trans($this->translator));
} else {
$worksheet->setCellValue($row, $formattedValue);
}
@@ -166,7 +136,7 @@ class SpreadsheetListFormatter implements FormatterInterface
++$i;
}
switch ($this->formatterData['format']) {
switch ($formatterData['format']) {
case 'ods':
$writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Ods');
$contentType = 'application/vnd.oasis.opendocument.spreadsheet';
@@ -189,26 +159,52 @@ class SpreadsheetListFormatter implements FormatterInterface
default:
// this should not happen
// throw an exception to ensure that the error is catched
throw new \OutOfBoundsException('The format '.$this->formatterData['format'].' is not supported');
throw new \OutOfBoundsException('The format '.$formatterData['format'].' is not supported');
}
$response = new Response();
$response->headers->set('content-type', $contentType);
$tempfile = \tempnam(\sys_get_temp_dir(), '');
$writer->save($tempfile);
$f = \fopen($tempfile, 'rb');
$response->setContent(\stream_get_contents($f));
fclose($f);
$generated = new FormattedExportGeneration(
file_get_contents($tempfile),
$contentType,
);
// remove the temp file from disk
\unlink($tempfile);
return $generated;
}
/**
* Generate a response from the data collected on differents ExportElementInterface.
*
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
*
* @return Response The response to be shown
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
ExportGenerationContext $context,
) {
$generated = $this->generate($result, $formatterData, $exportAlias, $exportData, $filtersData, $aggregatorsData, $context);
$response = new BinaryFileResponse($generated->content);
$response->headers->set('Content-Type', $generated->contentType);
return $response;
}
public function getType()
public function getType(): string
{
return FormatterInterface::TYPE_LIST;
}
@@ -216,34 +212,29 @@ class SpreadsheetListFormatter implements FormatterInterface
/**
* Give the label corresponding to the given key and value.
*
* @param string $key
* @param string $value
*
* @return string
* @return string|\DateTimeInterface|int|float|TranslatableInterface|null
*
* @throws \LogicException if the label is not found
*/
protected function getLabel($key, $value)
private function getLabel(array $labelsCache, $key, $value, array $result, string $exportAlias, array $exportData)
{
if (null === $this->labelsCache) {
$this->prepareCacheLabels();
if (!\array_key_exists($key, $labelsCache)) {
throw new \OutOfBoundsException(sprintf('The key "%s" is not present in the list of keys handled by this query. Check your `getKeys` and `getLabels` methods. Available keys are %s.', $key, \implode(', ', \array_keys($labelsCache))));
}
if (!\array_key_exists($key, $this->labelsCache)) {
throw new \OutOfBoundsException(sprintf('The key "%s" is not present in the list of keys handled by this query. Check your `getKeys` and `getLabels` methods. Available keys are %s.', $key, \implode(', ', \array_keys($this->labelsCache))));
}
return $this->labelsCache[$key]($value);
return $labelsCache[$key]($value);
}
/**
* Prepare the label cache which will be used by getLabel. This function
* should be called only once in the generation lifecycle.
* Prepare the label cache which will be used by getLabel.
*
* @return array The labels cache
*/
protected function prepareCacheLabels()
private function prepareCacheLabels(array $result, string $exportAlias, array $exportData): array
{
$export = $this->exportManager->getExport($this->exportAlias);
$keys = $export->getQueryKeys($this->exportData);
$labelsCache = [];
$export = $this->getExportManager()->getExport($exportAlias);
$keys = $export->getQueryKeys($exportData);
foreach ($keys as $key) {
// get an array with all values for this key if possible
@@ -253,29 +244,31 @@ class SpreadsheetListFormatter implements FormatterInterface
}
return $v[$key];
}, $this->result);
// store the label in the labelsCache property
$this->labelsCache[$key] = $export->getLabels($key, $values, $this->exportData);
}, $result);
// store the label in the labelsCache
$labelsCache[$key] = $export->getLabels($key, $values, $exportData);
}
return $labelsCache;
}
/**
* add the headers to the csv file.
*/
protected function prepareHeaders(Worksheet $worksheet)
protected function prepareHeaders(array $labelsCache, Worksheet $worksheet, array $result, array $formatterData, string $exportAlias, array $exportData)
{
$keys = $this->exportManager->getExport($this->exportAlias)->getQueryKeys($this->exportData);
$keys = $this->getExportManager()->getExport($exportAlias)->getQueryKeys($exportData);
// we want to keep the order of the first row. So we will iterate on the first row of the results
$first_row = \count($this->result) > 0 ? $this->result[0] : [];
$first_row = \count($result) > 0 ? $result[0] : [];
$header_line = [];
if (true === $this->formatterData['numerotation']) {
if (true === $formatterData['numerotation']) {
$header_line[] = $this->translator->trans('Number');
}
foreach ($first_row as $key => $value) {
$header_line[] = $this->translator->trans(
$this->getLabel($key, '_header')
$this->getLabel($labelsCache, $key, '_header', $result, $exportAlias, $exportData)
);
}

View File

@@ -12,7 +12,11 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatableInterface;
/**
* @method generate($result, $formatterData, string $exportAlias, array $exportData, array $filtersData, array $aggregatorsData, ExportGenerationContext $context): FormattedExportGeneration
*/
interface FormatterInterface
{
public const TYPE_LIST = 'list';
@@ -30,16 +34,16 @@ interface FormatterInterface
*/
public function buildForm(
FormBuilderInterface $builder,
$exportAlias,
string $exportAlias,
array $aggregatorAliases,
);
): void;
/**
* get the default data for the form build by buildForm.
*/
public function getFormDefaultData(array $aggregatorAliases): array;
public function getName();
public function getName(): string|TranslatableInterface;
/**
* Generate a response from the data collected on differents ExportElementInterface.
@@ -47,19 +51,28 @@ interface FormatterInterface
* @param mixed[] $result The result, as given by the ExportInterface
* @param mixed[] $formatterData collected from the current form
* @param string $exportAlias the id of the current export
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
* @param array $aggregatorsData an array containing the aggregators data. The key are the filters id, and the value are the data
* @param array $filtersData an array containing the filters data. The key are the filters id, and the value are the data
*
* @return \Symfony\Component\HttpFoundation\Response The response to be shown
*
* @deprecated use generate instead
*/
public function getResponse(
$result,
$formatterData,
$exportAlias,
array $result,
array $formatterData,
string $exportAlias,
array $exportData,
array $filtersData,
array $aggregatorsData,
ExportGenerationContext $context,
);
public function getType();
public function getType(): string;
public function normalizeFormData(array $formData): array;
public function denormalizeFormData(array $formData, int $fromVersion): array;
public function getNormalizationVersion(): int;
}

View File

@@ -0,0 +1,34 @@
<?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\Helper;
use Chill\MainBundle\Export\Exception\ExportRuntimeException;
use Chill\MainBundle\Export\ExportManager;
trait ExportManagerAwareTrait
{
private ?ExportManager $exportManager;
public function setExportManager(ExportManager $exportManager): void
{
$this->exportManager = $exportManager;
}
public function getExportManager(): ExportManager
{
if (null === $this->exportManager) {
throw new ExportRuntimeException('ExportManager not set');
}
return $this->exportManager;
}
}

View File

@@ -11,6 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\QueryBuilder;
/**
* Define methods to export list.
*
@@ -19,5 +22,10 @@ namespace Chill\MainBundle\Export;
* (and list does not support aggregation on their data).
*
* When used, the `ExportManager` will not handle aggregator for this class.
*
* @template Q of QueryBuilder|NativeQuery
* @template D of array
*
* @template-extends ExportInterface<Q, D>
*/
interface ListInterface extends ExportInterface {}

View File

@@ -0,0 +1,31 @@
<?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;
use Chill\MainBundle\Entity\User;
use Ramsey\Uuid\UuidInterface;
final readonly class ExportRequestGenerationMessage
{
public UuidInterface $id;
public int $userId;
public function __construct(
ExportGeneration $exportGeneration,
User $user,
) {
$this->id = $exportGeneration->getId();
$this->userId = $user->getId();
}
}

View File

@@ -0,0 +1,77 @@
<?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\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Export\ExportGenerator;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
#[AsMessageHandler]
final readonly class ExportRequestGenerationMessageHandler implements MessageHandlerInterface
{
private const LOG_PREFIX = '[export_generation] ';
public function __construct(
private ExportGenerationRepository $repository,
private UserRepositoryInterface $userRepository,
private ExportGenerator $exportGenerator,
private StoredObjectManagerInterface $storedObjectManager,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
) {}
public function __invoke(ExportRequestGenerationMessage $exportRequestGenerationMessage)
{
$start = microtime(true);
$this->logger->info(
self::LOG_PREFIX.'Handle generation message',
[
'exportId' => (string) $exportRequestGenerationMessage->id,
]
);
if (null === $exportGeneration = $this->repository->find($exportRequestGenerationMessage->id)) {
throw new \UnexpectedValueException('ExportRequestGenerationMessage not found');
}
if (null === $user = $this->userRepository->find($exportRequestGenerationMessage->userId)) {
throw new \UnexpectedValueException('User not found');
}
if (StoredObject::STATUS_PENDING !== $exportGeneration->getStatus()) {
throw new UnrecoverableMessageHandlingException('object already generated');
}
$generated = $this->exportGenerator->generate($exportGeneration->getExportAlias(), $exportGeneration->getOptions(), $user);
$this->storedObjectManager->write($exportGeneration->getStoredObject(), $generated->content, $generated->contentType);
$exportGeneration->getStoredObject()->setStatus(StoredObject::STATUS_READY);
$this->entityManager->flush();
$end = microtime(true);
$this->logger->notice(self::LOG_PREFIX.'Export generation successfully finished', [
'exportId' => (string) $exportRequestGenerationMessage->id,
'exportAlias' => $exportGeneration->getExportAlias(),
'full_generation_duration' => $end - $exportGeneration->getCreatedAt()->getTimestamp(),
'message_handler_duration' => $end - $start,
]);
}
}

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\Messenger;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
class OnExportGenerationFails implements EventSubscriberInterface
{
private const LOG_PREFIX = '[export_generation failed] ';
public function __construct(
private readonly LoggerInterface $logger,
private readonly ExportGenerationRepository $repository,
private readonly EntityManagerInterface $entityManager,
) {}
public static function getSubscribedEvents()
{
return [
WorkerMessageFailedEvent::class => 'onMessageFailed',
];
}
public function onMessageFailed(WorkerMessageFailedEvent $event): void
{
if ($event->willRetry()) {
return;
}
$message = $event->getEnvelope()->getMessage();
if (!$message instanceof ExportRequestGenerationMessage) {
return;
}
if (null === $exportGeneration = $this->repository->find($message->id)) {
throw new \UnexpectedValueException('ExportRequestGenerationMessage not found');
}
$this->logger->error(self::LOG_PREFIX.'ExportRequestGenerationMessage failed to execute generation', [
'exportId' => (string) $message->id,
'userId' => $message->userId,
'alias' => $exportGeneration->getExportAlias(),
'throwable_message' => $event->getThrowable()->getMessage(),
'throwable_trace' => $event->getThrowable()->getTraceAsString(),
'throwable' => $event->getThrowable()::class,
'full_generation_duration_failure' => microtime(true) - $exportGeneration->getCreatedAt()->getTimestamp(),
]);
$this->markObjectAsFailed($event, $exportGeneration);
$this->entityManager->flush();
}
private function markObjectAsFailed(WorkerMessageFailedEvent $event, ExportGeneration $exportGeneration): void
{
$exportGeneration->getStoredObject()->addGenerationErrors($event->getThrowable()->getMessage());
$exportGeneration->getStoredObject()->setStatus(StoredObject::STATUS_FAILURE);
}
}

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]
final readonly 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

@@ -0,0 +1,124 @@
<?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\Migrator;
use Chill\MainBundle\Service\RollingDate\RollingDate;
class SavedExportOptionsMigrator
{
public static function migrate(array $fromOptions): array
{
$to = [];
$to['aggregators'] = array_map(
self::mapEnabledStatus(...),
$fromOptions['export']['export']['aggregators'] ?? [],
);
$to['filters'] = array_map(
self::mapEnabledStatus(...),
$fromOptions['export']['export']['filters'] ?? [],
);
$to['export'] = [
'form' => array_map(
self::mapFormData(...),
array_filter(
$fromOptions['export']['export']['export'] ?? [],
static fn (string $key) => !in_array($key, ['filters', 'aggregators', 'pick_formatter'], true),
ARRAY_FILTER_USE_KEY,
),
),
'version' => 1,
];
$to['pick_formatter'] = $fromOptions['export']['export']['pick_formatter']['alias'] ?? null;
$to['centers'] = [
'centers' => array_values(array_map(static fn ($id) => (int) $id, $fromOptions['centers']['centers']['c'] ?? $fromOptions['centers']['centers']['center'] ?? [])),
'regroupments' => array_values(array_map(static fn ($id) => (int) $id, $fromOptions['centers']['centers']['regroupment'] ?? [])),
];
$to['formatter'] = [
'form' => $fromOptions['formatter']['formatter'] ?? [],
'version' => 1,
];
return $to;
}
private static function mapEnabledStatus(array $modifiersData): array
{
if ('1' === ($modifiersData['enabled'] ?? '0')) {
return [
'form' => array_map(self::mapFormData(...), $modifiersData['form'] ?? []),
'version' => 1,
'enabled' => true,
];
}
return ['enabled' => false];
}
private static function mapFormData(array|string $formData): array|string|null
{
if (is_array($formData) && array_key_exists('roll', $formData)) {
return self::refactorRollingDate($formData);
}
if (is_string($formData)) {
// we try different date formats
if (false !== \DateTimeImmutable::createFromFormat('d-m-Y', $formData)) {
return $formData;
}
if (false !== \DateTimeImmutable::createFromFormat('Y-m-d', $formData)) {
return $formData;
}
// we try json content
try {
$data = json_decode($formData, true, 512, JSON_THROW_ON_ERROR);
if (is_array($data)) {
if (array_key_exists('type', $data) && array_key_exists('id', $data) && in_array($data['type'], ['person', 'thirdParty', 'user'], true)) {
return $data['id'];
}
$response = [];
foreach ($data as $item) {
if (array_key_exists('type', $item) && array_key_exists('id', $item) && in_array($item['type'], ['person', 'thirdParty', 'user'], true)) {
$response[] = $item['id'];
}
}
if ([] !== $response) {
return $response;
}
}
} catch (\JsonException) {
return $formData;
}
}
return $formData;
}
private static function refactorRollingDate(array $formData): ?array
{
if ('' === $formData['roll']) {
return null;
}
$fixedDate = null !== ($formData['fixedDate'] ?? null) && '' !== $formData['fixedDate'] ?
\DateTimeImmutable::createFromFormat('Y-m-d H:i:s', sprintf('%s 00:00:00', $formData['fixedDate']), new \DateTimeZone(date_default_timezone_get())) : null;
return (new RollingDate(
$formData['roll'],
$fixedDate,
))->normalize();
}
}

View File

@@ -37,12 +37,12 @@ interface ModifierInterface extends ExportElementInterface
* @param QueryBuilder $qb the QueryBuilder initiated by the Export (and eventually modified by other Modifiers)
* @param mixed[] $data the data from the Form (builded by buildForm)
*/
public function alterQuery(QueryBuilder $qb, $data);
public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void;
/**
* On which type of Export this ModifiersInterface may apply.
*
* @return string the type on which the Modifiers apply
*/
public function applyOn();
public function applyOn(): string;
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class SortExportElement
@@ -19,12 +20,21 @@ final readonly class SortExportElement
private TranslatorInterface $translator,
) {}
private function trans(string|TranslatableInterface $message): string
{
if ($message instanceof TranslatableInterface) {
return $message->trans($this->translator, $this->translator->getLocale());
}
return $this->translator->trans($message);
}
/**
* @param array<int|string, FilterInterface> $elements
*/
public function sortFilters(array &$elements): void
{
uasort($elements, fn (FilterInterface $a, FilterInterface $b) => $this->translator->trans($a->getTitle()) <=> $this->translator->trans($b->getTitle()));
uasort($elements, fn (FilterInterface $a, FilterInterface $b) => $this->trans($a->getTitle()) <=> $this->trans($b->getTitle()));
}
/**
@@ -32,6 +42,6 @@ final readonly class SortExportElement
*/
public function sortAggregators(array &$elements): void
{
uasort($elements, fn (AggregatorInterface $a, AggregatorInterface $b) => $this->translator->trans($a->getTitle()) <=> $this->translator->trans($b->getTitle()));
uasort($elements, fn (AggregatorInterface $a, AggregatorInterface $b) => $this->trans($a->getTitle()) <=> $this->trans($b->getTitle()));
}
}

View File

@@ -1,56 +0,0 @@
<?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\Form\DataMapper;
use Chill\MainBundle\Entity\Regroupment;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormInterface;
final readonly class ExportPickCenterDataMapper implements DataMapperInterface
{
public function mapDataToForms($viewData, \Traversable $forms): void
{
if (null === $viewData) {
return;
}
/** @var array<string, FormInterface> $form */
$form = iterator_to_array($forms);
$form['center']->setData($viewData);
// NOTE: we do not map back the regroupments
}
public function mapFormsToData(\Traversable $forms, &$viewData): void
{
/** @var array<string, FormInterface> $forms */
$forms = iterator_to_array($forms);
$centers = [];
foreach ($forms['center']->getData() as $center) {
$centers[spl_object_hash($center)] = $center;
}
if (\array_key_exists('regroupment', $forms)) {
/** @var Regroupment $regroupment */
foreach ($forms['regroupment']->getData() as $regroupment) {
foreach ($regroupment->getCenters() as $center) {
$centers[spl_object_hash($center)] = $center;
}
}
}
$viewData = array_values($centers);
}
}

View File

@@ -13,15 +13,22 @@ namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Security;
class SavedExportType extends AbstractType
{
public function __construct(private readonly Security $security) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$savedExport = $options['data'];
$builder
->add('title', TextType::class, [
'required' => true,
@@ -29,6 +36,14 @@ class SavedExportType extends AbstractType
->add('description', ChillTextareaType::class, [
'required' => false,
]);
if ($this->security->isGranted(SavedExportVoter::SHARE, $savedExport)) {
$builder->add('share', PickUserGroupOrUserDynamicType::class, [
'multiple' => true,
'required' => false,
'label' => 'saved_export.Share',
]);
}
}
public function configureOptions(OptionsResolver $resolver)

View File

@@ -66,8 +66,11 @@ class EntityToJsonTransformer implements DataTransformerInterface
]);
}
private function denormalizeOne(array $item)
private function denormalizeOne(array|string $item)
{
if ('me' === $item) {
return $item;
}
if (!\array_key_exists('type', $item)) {
throw new TransformationFailedException('the key "type" is missing on element');
}
@@ -98,5 +101,6 @@ class EntityToJsonTransformer implements DataTransformerInterface
'json',
$context,
);
}
}

View File

@@ -21,15 +21,18 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class AggregatorType extends AbstractType
{
public const ENABLED_FIELD = 'enabled';
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$exportManager = $options['export_manager'];
$aggregator = $exportManager->getAggregator($options['aggregator_alias']);
$builder
->add('enabled', CheckboxType::class, [
->add(self::ENABLED_FIELD, CheckboxType::class, [
'value' => true,
'required' => false,
'disabled' => $options['disable_enable_field'],
]);
$aggregatorFormBuilder = $builder->create('form', FormType::class, [
@@ -53,6 +56,7 @@ class AggregatorType extends AbstractType
{
$resolver->setRequired('aggregator_alias')
->setRequired('export_manager')
->setDefault('disable_enable_field', false)
->setDefault('compound', true)
->setDefault('error_bubbling', false);
}

View File

@@ -35,7 +35,7 @@ class ExportType extends AbstractType
public function __construct(
private readonly ExportManager $exportManager,
private readonly SortExportElement $sortExportElement,
protected ParameterBagInterface $parameterBag,
ParameterBagInterface $parameterBag,
) {
$this->personFieldsConfig = $parameterBag->get('chill_person.person_fields');
}
@@ -43,6 +43,8 @@ class ExportType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$export = $this->exportManager->getExport($options['export_alias']);
/** @var bool $canEditFull */
$canEditFull = $options['can_edit_full'];
$exportOptions = [
'compound' => true,
@@ -59,8 +61,18 @@ class ExportType extends AbstractType
if ($export instanceof \Chill\MainBundle\Export\ExportInterface) {
// add filters
$filters = $this->exportManager->getFiltersApplyingOn($export, $options['picked_centers']);
$filterAliases = $options['allowed_filters'];
$filters = [];
if (is_iterable($filterAliases)) {
foreach ($filterAliases as $alias => $filter) {
$filters[$alias] = $filter;
}
} else {
$filters = $this->exportManager->getFiltersApplyingOn($export, $options['picked_centers']);
}
$this->sortExportElement->sortFilters($filters);
$filterBuilder = $builder->create(self::FILTER_KEY, FormType::class, ['compound' => true]);
foreach ($filters as $alias => $filter) {
@@ -70,15 +82,26 @@ class ExportType extends AbstractType
'constraints' => [
new ExportElementConstraint(['element' => $filter]),
],
'disable_enable_field' => !$canEditFull,
]);
}
$builder->add($filterBuilder);
// add aggregators
$aggregators = $this->exportManager
->getAggregatorsApplyingOn($export, $options['picked_centers']);
$aggregatorsAliases = $options['allowed_aggregators'];
$aggregators = [];
if (is_iterable($aggregatorsAliases)) {
foreach ($aggregatorsAliases as $alias => $aggregator) {
$aggregators[$alias] = $aggregator;
}
} else {
$aggregators = $this->exportManager
->getAggregatorsApplyingOn($export, $options['picked_centers']);
}
$this->sortExportElement->sortAggregators($aggregators);
$aggregatorBuilder = $builder->create(
self::AGGREGATOR_KEY,
FormType::class,
@@ -96,11 +119,11 @@ class ExportType extends AbstractType
}
}
$aggregatorBuilder->add($alias, AggregatorType::class, [
'aggregator_alias' => $alias,
'export_manager' => $this->exportManager,
'label' => $aggregator->getTitle(),
'disable_enable_field' => !$canEditFull,
'constraints' => [
new ExportElementConstraint(['element' => $aggregator]),
],
@@ -125,8 +148,13 @@ class ExportType extends AbstractType
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setRequired(['export_alias', 'picked_centers'])
$resolver->setRequired(['export_alias', 'picked_centers', 'can_edit_full'])
->setAllowedTypes('export_alias', ['string'])
->setAllowedValues('can_edit_full', [true, false])
->setDefault('allowed_filters', null)
->setAllowedTypes('allowed_filters', ['iterable', 'null'])
->setDefault('allowed_aggregators', null)
->setAllowedTypes('allowed_aggregators', ['iterable', 'null'])
->setDefault('compound', true)
->setDefault('constraints', [
// new \Chill\MainBundle\Validator\Constraints\Export\ExportElementConstraint()

View File

@@ -34,6 +34,7 @@ class FilterType extends AbstractType
->add(self::ENABLED_FIELD, CheckboxType::class, [
'value' => true,
'required' => false,
'disabled' => $options['disable_enable_field'],
]);
$filterFormBuilder = $builder->create('form', FormType::class, [
@@ -58,6 +59,7 @@ class FilterType extends AbstractType
$resolver
->setRequired('filter')
->setAllowedTypes('filter', [FilterInterface::class])
->setDefault('disable_enable_field', false)
->setDefault('compound', true)
->setDefault('error_bubbling', false);
}

View File

@@ -14,9 +14,9 @@ namespace Chill\MainBundle\Form\Type\Export;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Form\DataMapper\ExportPickCenterDataMapper;
use Chill\MainBundle\Repository\RegroupmentRepository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Service\Regroupement\RegroupementFiltering;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -27,27 +27,26 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class PickCenterType extends AbstractType
{
public const CENTERS_IDENTIFIERS = 'c';
public function __construct(
private readonly ExportManager $exportManager,
private readonly RegroupmentRepository $regroupmentRepository,
private readonly AuthorizationHelperForCurrentUserInterface $authorizationHelper,
private readonly RegroupementFiltering $regroupementFiltering,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$export = $this->exportManager->getExport($options['export_alias']);
$centers = $this->authorizationHelper->getReachableCenters(
$export->requiredRole()
$export->requiredRole(),
);
$centersActive = array_filter($centers, fn (Center $c) => $c->getIsActive());
// order alphabetically
usort($centersActive, fn (Center $a, Center $b) => $a->getCenter() <=> $b->getName());
usort($centersActive, fn (Center $a, Center $b) => $a->getName() <=> $b->getName());
$builder->add('center', EntityType::class, [
$builder->add('centers', EntityType::class, [
'class' => Center::class,
'choices' => $centersActive,
'label' => 'center',
@@ -56,18 +55,22 @@ final class PickCenterType extends AbstractType
'choice_label' => static fn (Center $c) => $c->getName(),
]);
if (\count($this->regroupmentRepository->findAllActive()) > 0) {
$builder->add('regroupment', EntityType::class, [
$groups = $this->regroupementFiltering
->filterContainsAtLeastOneCenter($this->regroupmentRepository->findAllActive(), $centersActive);
// order alphabetically
usort($groups, fn (Regroupment $a, Regroupment $b) => $a->getName() <=> $b->getName());
if (\count($groups) > 0) {
$builder->add('regroupments', EntityType::class, [
'class' => Regroupment::class,
'label' => 'regroupment',
'multiple' => true,
'expanded' => true,
'choices' => $this->regroupmentRepository->findAllActive(),
'choices' => $groups,
'choice_label' => static fn (Regroupment $r) => $r->getName(),
]);
}
$builder->setDataMapper(new ExportPickCenterDataMapper());
}
public function configureOptions(OptionsResolver $resolver)

View File

@@ -0,0 +1,82 @@
<?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\Form\Type;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\DataTransformer\EntityToJsonTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Pick user dymically, using vuejs module "AddPerson".
*
* Possible options:
*
* - `multiple`: pick one or more users
* - `suggested`: a list of suggested users
* - `suggest_myself`: append the current user to the list of suggested
* - `as_id`: only the id will be set in the returned data
* - `submit_on_adding_new_entity`: the browser will immediately submit the form when new users are checked
*/
class PickUserOrMeDynamicType extends AbstractType
{
public function __construct(
private readonly DenormalizerInterface $denormalizer,
private readonly SerializerInterface $serializer,
private readonly NormalizerInterface $normalizer,
) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addViewTransformer(new EntityToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple'], 'user'));
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['multiple'] = $options['multiple'];
$view->vars['types'] = ['user'];
$view->vars['uniqid'] = uniqid('pick_user_or_me_dyn');
$view->vars['suggested'] = [];
$view->vars['as_id'] = true === $options['as_id'] ? '1' : '0';
$view->vars['submit_on_adding_new_entity'] = true === $options['submit_on_adding_new_entity'] ? '1' : '0';
foreach ($options['suggested'] as $user) {
$view->vars['suggested'][] = $this->normalizer->normalize($user, 'json', ['groups' => 'read']);
}
// $user = /* should come from context */ $options['context'];
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggested', [])
// if set to true, only the id will be set inside the content. The denormalization will not work.
->setDefault('as_id', false)
->setAllowedTypes('as_id', ['bool'])
->setDefault('submit_on_adding_new_entity', false)
->setAllowedTypes('submit_on_adding_new_entity', ['bool']);
}
public function getBlockPrefix()
{
return 'pick_entity_dynamic';
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType;
@@ -23,6 +24,9 @@ class UserGroupType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var UserGroup $userGroup */
$userGroup = $options['data'];
$builder
->add('label', TranslatableStringFormType::class, [
'label' => 'user_group.Label',
@@ -46,20 +50,25 @@ class UserGroupType extends AbstractType
'help' => 'user_group.ExcludeKeyHelp',
'required' => false,
'empty_data' => '',
])
->add('users', PickUserDynamicType::class, [
'label' => 'user_group.Users',
'multiple' => true,
'required' => false,
'empty_data' => [],
])
->add('adminUsers', PickUserDynamicType::class, [
'label' => 'user_group.adminUsers',
'multiple' => true,
'required' => false,
'empty_data' => [],
'help' => 'user_group.adminUsersHelp',
])
;
]);
if (!$userGroup->hasUserJob()) {
$builder
->add('users', PickUserDynamicType::class, [
'label' => 'user_group.Users',
'multiple' => true,
'required' => false,
'empty_data' => [],
])
->add('adminUsers', PickUserDynamicType::class, [
'label' => 'user_group.adminUsers',
'multiple' => true,
'required' => false,
'empty_data' => [],
'help' => 'user_group.adminUsersHelp',
])
;
}
}
}

View File

@@ -0,0 +1,85 @@
<?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\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ExportGeneration>
*
* @implements AssociatedEntityToStoredObjectInterface<ExportGeneration>
*/
class ExportGenerationRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ExportGeneration::class);
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?ExportGeneration
{
return $this->createQueryBuilder('e')
->where('e.storedObject = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery()
->getOneOrNullResult();
}
/**
* @return list<ExportGeneration>
*/
public function findExportGenerationByAliasAndUser(string $alias, User $user, int $limit = 100, int $offset = 0): array
{
return $this->createQueryBuilder('e')
->where('e.createdBy = :user')
->andWhere('e.exportAlias LIKE :alias')
->orderBy('e.createdAt', 'DESC')
->setParameter('user', $user)
->setParameter('alias', $alias)
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* @return list<ExportGeneration>
*/
public function findExportGenerationBySavedExportAndUser(SavedExport $savedExport, User $user, int $limit = 100, int $offset = 0): array
{
return $this->createQueryBuilder('e')
->where('e.createdBy = :user')
->andWhere('e.savedExport = :savedExport')
->orderBy('e.createdAt', 'DESC')
->setParameter('user', $user)
->setParameter('savedExport', $savedExport)
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function findExpiredExportGeneration(\DateTimeImmutable $atDate): iterable
{
return $this->createQueryBuilder('e')
->where('e.deleteAt < :atDate')
->setParameter('atDate', $atDate)
->getQuery()
->toIterable();
}
}

View File

@@ -13,6 +13,7 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\GeographicalUnit;
use Chill\MainBundle\Entity\GeographicalUnit\SimpleGeographicalUnitDTO;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
@@ -42,7 +43,7 @@ final readonly class GeographicalUnitRepository implements GeographicalUnitRepos
$qb = $this->buildQueryGeographicalUnitContainingAddress($address);
return $qb
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', GeographicalUnit\SimpleGeographicalUnitDTO::class))
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', SimpleGeographicalUnitDTO::class))
->addOrderBy('IDENTITY(gu.layer)')
->addOrderBy('gu.unitName')
->getQuery()
@@ -58,7 +59,7 @@ final readonly class GeographicalUnitRepository implements GeographicalUnitRepos
;
return $qb
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', GeographicalUnit\SimpleGeographicalUnitDTO::class))
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', SimpleGeographicalUnitDTO::class))
->innerJoin(Address::class, 'address', Join::WITH, 'ST_CONTAINS(gu.geom, address.point) = TRUE')
->where($qb->expr()->eq('address', ':address'))
->setParameter('address', $address)
@@ -70,6 +71,19 @@ final readonly class GeographicalUnitRepository implements GeographicalUnitRepos
return $this->repository->find($id);
}
public function findSimpleGeographicalUnit(int $id): ?SimpleGeographicalUnitDTO
{
$qb = $this->repository
->createQueryBuilder('gu');
return $qb
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', SimpleGeographicalUnitDTO::class))
->where('gu.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
/**
* Will return only partial object, where the @see{GeographicalUnit::geom} property is not loaded.
*
@@ -79,7 +93,7 @@ final readonly class GeographicalUnitRepository implements GeographicalUnitRepos
{
return $this->repository
->createQueryBuilder('gu')
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', GeographicalUnit\SimpleGeographicalUnitDTO::class))
->select(sprintf('NEW %s(gu.id, gu.unitName, gu.unitRefId, IDENTITY(gu.layer))', SimpleGeographicalUnitDTO::class))
->addOrderBy('IDENTITY(gu.layer)')
->addOrderBy('gu.unitName')
->getQuery()

View File

@@ -27,4 +27,6 @@ interface GeographicalUnitRepositoryInterface extends ObjectRepository
public function findGeographicalUnitContainingAddress(Address $address, int $offset = 0, int $limit = 50): array;
public function countGeographicalUnitContainingAddress(Address $address): int;
public function findSimpleGeographicalUnit(int $id): ?SimpleGeographicalUnitDTO;
}

View File

@@ -16,9 +16,8 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ObjectRepository;
final readonly class RegroupmentRepository implements ObjectRepository
final readonly class RegroupmentRepository implements RegroupmentRepositoryInterface
{
private EntityRepository $repository;

View File

@@ -0,0 +1,34 @@
<?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\Repository;
use Chill\MainBundle\Entity\Regroupment;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ObjectRepository;
/**
* @template-extends ObjectRepository<Regroupment>
*/
interface RegroupmentRepositoryInterface extends ObjectRepository
{
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function findOneByName(string $name): ?Regroupment;
/**
* @return array<Regroupment>
*/
public function findRegroupmentAssociatedToNoCenter(): array;
}

View File

@@ -0,0 +1,32 @@
<?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\Repository;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
readonly class SavedExportOrExportGenerationRepository
{
public function __construct(
private SavedExportRepositoryInterface $savedExportRepository,
private ExportGenerationRepository $exportGenerationRepository,
) {}
public function findById(string $uuid): SavedExport|ExportGeneration|null
{
if (null !== $savedExport = $this->savedExportRepository->find($uuid)) {
return $savedExport;
}
return $this->exportGenerationRepository->find($uuid);
}
}

View File

@@ -13,9 +13,12 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\String\UnicodeString;
/**
* @implements ObjectRepository<SavedExport>
@@ -55,6 +58,51 @@ class SavedExportRepository implements SavedExportRepositoryInterface
->where($qb->expr()->eq('se.user', ':user'))
->setParameter('user', $user);
return $this->prepareResult($qb, $orderBy, $limit, $offset);
}
public function findSharedWithUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null, array $filters = []): array
{
$qb = $this->repository->createQueryBuilder('se');
$qb
->where(
$qb->expr()->orX(
$qb->expr()->eq('se.user', ':user'),
$qb->expr()->isMemberOf(':user', 'se.sharedWithUsers'),
$qb->expr()->exists(
sprintf('SELECT 1 FROM %s ug WHERE ug MEMBER OF se.sharedWithGroups AND :user MEMBER OF ug.users', UserGroup::class)
)
)
)
->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);
}
private function prepareResult(QueryBuilder $qb, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array
{
if (null !== $limit) {
$qb->setMaxResults($limit);
}

View File

@@ -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,6 +37,15 @@ interface SavedExportRepositoryInterface extends ObjectRepository
*/
public function findByUser(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 getClassName(): string;

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\EntityManagerInterface;
@@ -30,9 +31,6 @@ readonly class UserJobRepository implements UserJobRepositoryInterface
return $this->repository->find($id);
}
/**
* @return array|UserJob[]
*/
public function findAll(): array
{
return $this->repository->findAll();
@@ -56,12 +54,20 @@ readonly class UserJobRepository implements UserJobRepositoryInterface
return $jobs;
}
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return array|object[]|UserJob[]
*/
public function findAllNotAssociatedWithUserGroup(): array
{
$qb = $this->repository->createQueryBuilder('u');
$qb->select('u');
$qb->where(
$qb->expr()->not(
$qb->expr()->exists(sprintf('SELECT 1 FROM %s ug WHERE ug.userJob = u', UserGroup::class))
)
);
return $qb->getQuery()->getResult();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);

View File

@@ -14,18 +14,15 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\UserJob;
use Doctrine\Persistence\ObjectRepository;
/**
* @template-extends ObjectRepository<UserJob>
*/
interface UserJobRepositoryInterface extends ObjectRepository
{
public function find($id): ?UserJob;
/**
* @return array|UserJob[]
*/
public function findAll(): array;
/**
* @return array|UserJob[]
*/
public function findAllActive(): array;
/**
@@ -36,11 +33,14 @@ interface UserJobRepositoryInterface extends ObjectRepository
public function findAllOrderedByName(): array;
/**
* @param mixed|null $limit
* @param mixed|null $offset
* Find all the user job which are not related to a UserGroup.
*
* @return array|object[]|UserJob[]
* This is useful for synchronizing UserGroups with jobs.
*
* @return list<UserJob>
*/
public function findAllNotAssociatedWithUserGroup(): array;
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null);
public function findOneBy(array $criteria): ?UserJob;

View File

@@ -0,0 +1,18 @@
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import { ExportGeneration } from "ChillMainAssets/types";
export const fetchExportGenerationStatus = async (
exportGenerationId: string,
): Promise<ExportGeneration> =>
makeFetch(
"GET",
`/api/1.0/main/export-generation/${exportGenerationId}/object`,
);
export const generateFromSavedExport = async (
savedExportUuid: string,
): Promise<ExportGeneration> =>
makeFetch(
"POST",
`/api/1.0/main/export/export-generation/create-from-saved-export/${savedExportUuid}`,
);

View File

@@ -0,0 +1,3 @@
export function buildReturnPath(location: Location): string {
return location.pathname + location.search;
}

View File

@@ -12,6 +12,11 @@ function loadDynamicPicker(element) {
let apps = element.querySelectorAll('[data-module="pick-dynamic"]');
apps.forEach(function (el) {
let suggested;
let as_id;
let submit_on_adding_new_entity;
let label;
let isCurrentUserPicker;
const isMultiple = parseInt(el.dataset.multiple) === 1,
uniqId = el.dataset.uniqid,
input = element.querySelector(
@@ -22,12 +27,13 @@ function loadDynamicPicker(element) {
? JSON.parse(input.value)
: input.value === "[]" || input.value === ""
? null
: [JSON.parse(input.value)],
suggested = JSON.parse(el.dataset.suggested),
as_id = parseInt(el.dataset.asId) === 1,
submit_on_adding_new_entity =
parseInt(el.dataset.submitOnAddingNewEntity) === 1,
label = el.dataset.label;
: [JSON.parse(input.value)];
suggested = JSON.parse(el.dataset.suggested);
as_id = parseInt(el.dataset.asId) === 1;
submit_on_adding_new_entity =
parseInt(el.dataset.submitOnAddingNewEntity) === 1;
label = el.dataset.label;
isCurrentUserPicker = uniqId.startsWith("pick_user_or_me_dyn");
if (!isMultiple) {
if (input.value === "[]") {
@@ -44,6 +50,7 @@ function loadDynamicPicker(element) {
':uniqid="uniqid" ' +
':suggested="notPickedSuggested" ' +
':label="label" ' +
':isCurrentUserPicker="isCurrentUserPicker" ' +
'@addNewEntity="addNewEntity" ' +
'@removeEntity="removeEntity" ' +
'@addNewEntityProcessEnded="addNewEntityProcessEnded"' +
@@ -61,6 +68,7 @@ function loadDynamicPicker(element) {
as_id,
submit_on_adding_new_entity,
label,
isCurrentUserPicker,
};
},
computed: {
@@ -89,7 +97,8 @@ function loadDynamicPicker(element) {
const ids = this.picked.map((el) => el.id);
input.value = ids.join(",");
}
console.log(entity);
console.log(this.picked);
// console.log(entity);
}
} else {
if (

View File

@@ -1,14 +0,0 @@
import { download_report } from "../../lib/download-report/download-report";
window.addEventListener("DOMContentLoaded", function (e) {
const export_generate_url = window.export_generate_url;
if (typeof export_generate_url === "undefined") {
console.error("Alias not found!");
throw new Error("Alias not found!");
}
const query = window.location.search,
container = document.querySelector("#download_container");
download_report(export_generate_url + query.toString(), container);
});

View File

@@ -1,4 +1,5 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
export interface DateTime {
datetime: string;
@@ -201,6 +202,16 @@ export interface WorkflowAttachment {
genericDoc: null | GenericDoc;
}
export interface ExportGeneration {
id: string;
type: "export_generation";
exportAlias: string;
createdBy: User | null;
createdAt: DateTime | null;
status: StoredObjectStatus;
storedObject: StoredObject;
}
export interface PrivateCommentEmbeddable {
comments: Record<number, string>;
}

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import {
trans,
EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING,
EXPORT_GENERATION_TOO_MANY_RETRIES,
EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT,
EXPORT_GENERATION_EXPORT_READY,
} from "translator";
import { computed, onMounted, ref } from "vue";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import { ExportGeneration } from "ChillMainAssets/types";
interface AppProps {
exportGenerationId: string;
title: string;
createdDate: string;
}
const props = defineProps<AppProps>();
const exportGeneration = ref<ExportGeneration | null>(null);
const status = computed<StoredObjectStatus>(
() => exportGeneration.value?.status ?? "pending",
);
const storedObject = computed<null | StoredObject>(() => {
if (exportGeneration.value === null) {
return null;
}
return exportGeneration.value?.storedObject;
});
const isPending = computed<boolean>(() => status.value === "pending");
const isFetching = computed<boolean>(
() => tryiesForReady.value < maxTryiesForReady,
);
const isReady = computed<boolean>(() => status.value === "ready");
const isFailure = computed<boolean>(() => status.value === "failure");
const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
/**
* counter for the number of times that we check for a new status
*/
let tryiesForReady = ref<number>(0);
/**
* how many times we may check for a new status, once loaded
*/
const maxTryiesForReady = 120;
const checkForReady = function (): void {
if (
"ready" === status.value ||
"empty" === status.value ||
"failure" === status.value ||
// stop reloading if the page stays opened for a long time
tryiesForReady.value > maxTryiesForReady
) {
return;
}
tryiesForReady.value = tryiesForReady.value + 1;
setTimeout(onObjectNewStatusCallback, 5000);
};
const onObjectNewStatusCallback = async function (): Promise<void> {
exportGeneration.value = await fetchExportGenerationStatus(
props.exportGenerationId,
);
if (isPending.value) {
checkForReady();
return Promise.resolve();
}
return Promise.resolve();
};
onMounted(() => {
onObjectNewStatusCallback();
});
</script>
<template>
<div id="waiting-screen">
<div
v-if="isPending && isFetching"
class="alert alert-danger text-center"
>
<div>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
</p>
</div>
<div>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-if="isPending && !isFetching" class="alert alert-info">
<div>
<p>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
</p>
</div>
</div>
<div v-if="isFailure" class="alert alert-danger text-center">
<div>
<p>
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
</p>
</div>
</div>
<div v-if="isReady" class="alert alert-success text-center">
<div>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
</p>
<p v-if="storedObject !== null">
<document-action-buttons-group
:stored-object="storedObject"
:filename="filename"
></document-action-buttons-group>
</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>

View File

@@ -0,0 +1,15 @@
import { createApp } from "vue";
import App from "./App.vue";
const el = document.getElementById("app");
if (null === el) {
console.error("div element app was not found");
throw new Error("div element app was not found");
}
const exportGenerationId = el?.dataset.exportGenerationId as string;
const title = el?.dataset.exportTitle as string;
const createdDate = el?.dataset.exportGenerationDate as string;
createApp(App, { exportGenerationId, title, createdDate }).mount(el);

View File

@@ -1,10 +1,26 @@
<template>
<ul :class="listClasses" v-if="picked.length && displayPicked">
<li v-for="p in picked" @click="removeEntity(p)" :key="p.type + p.id">
<span class="chill_denomination">{{ p.text }}</span>
<span
v-if="'me' === p"
class="chill_denomination current-user updatedBy"
>{{ trans(USER_CURRENT_USER) }}</span
>
<span v-else class="chill_denomination">{{ p.text }}</span>
</li>
</ul>
<ul class="record_actions">
<li v-if="isCurrentUserPicker" class="btn btn-sm btn-misc">
<label class="flex items-center gap-2">
<input
:checked="picked.indexOf('me') >= 0 ? true : null"
ref="itsMeCheckbox"
:type="multiple ? 'checkbox' : 'radio'"
@change="selectItsMe"
/>
{{ trans(USER_CURRENT_USER) }}
</label>
</li>
<li class="add-persons">
<add-persons
:options="addPersonsOptions"
@@ -24,119 +40,83 @@
</ul>
</template>
<script>
import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue";
<script setup>
import { ref, computed } from "vue";
import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue"; // eslint-disable-line
import { appMessages } from "./i18n";
import { trans, USER_CURRENT_USER } from "translator";
export default {
name: "PickEntity",
props: {
multiple: {
type: Boolean,
required: true,
},
types: {
type: Array,
required: true,
},
picked: {
required: true,
},
uniqid: {
type: String,
required: true,
},
removableIfSet: {
type: Boolean,
default: true,
},
displayPicked: {
// display picked entities.
type: Boolean,
default: true,
},
suggested: {
type: Array,
default: [],
},
label: {
type: String,
required: false,
},
},
emits: ["addNewEntity", "removeEntity", "addNewEntityProcessEnded"],
components: {
AddPersons,
},
data() {
return {
key: "",
};
},
computed: {
addPersonsOptions() {
return {
uniq: !this.multiple,
type: this.types,
priority: null,
button: {
size: "btn-sm",
class: "btn-submit",
},
};
},
translatedListOfTypes() {
if (this.label !== "") {
return this.label;
}
const props = defineProps({
multiple: Boolean,
types: Array,
picked: Array,
uniqid: String,
removableIfSet: { type: Boolean, default: true },
displayPicked: { type: Boolean, default: true },
suggested: { type: Array, default: () => [] },
label: String,
isCurrentUserPicker: { type: Boolean, default: false },
});
let trans = [];
this.types.forEach((t) => {
if (this.$props.multiple) {
trans.push(appMessages.fr.pick_entity[t].toLowerCase());
} else {
trans.push(
appMessages.fr.pick_entity[t + "_one"].toLowerCase(),
);
}
});
const emit = defineEmits([
"addNewEntity",
"removeEntity",
"addNewEntityProcessEnded",
]);
if (this.$props.multiple) {
return (
appMessages.fr.pick_entity.modal_title + trans.join(", ")
);
} else {
return (
appMessages.fr.pick_entity.modal_title_one +
trans.join(", ")
);
}
},
listClasses() {
return {
"list-suggest": true,
"remove-items": this.$props.removableIfSet,
};
},
},
methods: {
addNewSuggested(entity) {
this.$emit("addNewEntity", { entity: entity });
},
addNewEntity({ selected, modal }) {
selected.forEach((item) => {
this.$emit("addNewEntity", { entity: item.result });
}, this);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
this.$emit("addNewEntityProcessEnded");
},
removeEntity(entity) {
if (!this.$props.removableIfSet) {
return;
}
this.$emit("removeEntity", { entity: entity });
},
},
const itsMeCheckbox = ref(null);
const addPersons = ref(null);
const addPersonsOptions = computed(() => ({
uniq: !props.multiple,
type: props.types,
priority: null,
button: { size: "btn-sm", class: "btn-submit" },
}));
const translatedListOfTypes = computed(() => {
if (props.label) return props.label;
let trans = props.types.map((t) =>
props.multiple
? appMessages.fr.pick_entity[t].toLowerCase()
: appMessages.fr.pick_entity[t + "_one"].toLowerCase(),
);
return props.multiple
? appMessages.fr.pick_entity.modal_title + trans.join(", ")
: appMessages.fr.pick_entity.modal_title_one + trans.join(", ");
});
const listClasses = computed(() => ({
"list-suggest": true,
"remove-items": props.removableIfSet,
}));
const selectItsMe = (event) =>
event.target.checked ? addNewSuggested("me") : removeEntity("me");
const addNewSuggested = (entity) => {
emit("addNewEntity", { entity });
};
const addNewEntity = ({ selected, modal }) => {
selected.forEach((item) => emit("addNewEntity", { entity: item.result }));
addPersons.value?.resetSearch();
modal.showModal = false;
emit("addNewEntityProcessEnded");
};
const removeEntity = (entity) => {
if (!props.removableIfSet) return;
if (entity === "me" && itsMeCheckbox.value) {
itsMeCheckbox.value.checked = false;
}
emit("removeEntity", { entity });
};
</script>
<style lang="scss" scoped>
.current-user {
color: var(--bs-body-color);
background-color: var(--bs-chill-l-gray) !important;
}
</style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import GenerateButton from "ChillMainAssets/vuejs/SavedExportButtons/Component/GenerateButton.vue";
interface SavedExportButtonsConfig {
savedExportUuid: string;
savedExportAlias: string;
}
const props = defineProps<SavedExportButtonsConfig>();
</script>
<template>
<generate-button
:saved-export-uuid="props.savedExportUuid"
></generate-button>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import {
trans,
SAVED_EXPORT_EXECUTE,
EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING_SHORT,
EXPORT_GENERATION_TOO_MANY_RETRIES,
EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT,
EXPORT_GENERATION_EXPORT_READY,
} from "translator";
import {
fetchExportGenerationStatus,
generateFromSavedExport,
} from "ChillMainAssets/lib/api/export";
import { computed, ref } from "vue";
import { ExportGeneration } from "ChillMainAssets/types";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";
import { useToast } from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css";
interface SavedExportButtonGenerateConfig {
savedExportUuid: string;
}
const props = defineProps<SavedExportButtonGenerateConfig>();
const emits = defineEmits<{
(e: "generate");
}>();
const toast = useToast();
const exportGeneration = ref<ExportGeneration | null>(null);
const status = computed<StoredObjectStatus | "inactive">(
() => exportGeneration.value?.status ?? "inactive",
);
const storedObject = computed<null | StoredObject>(() => {
if (exportGeneration.value === null) {
return null;
}
return exportGeneration.value?.storedObject;
});
const isInactive = computed<boolean>(() => status.value === "inactive");
const isPending = computed<boolean>(() => status.value === "pending");
const isFetching = computed<boolean>(
() => tryiesForReady.value < maxTryiesForReady,
);
const isReady = computed<boolean>(() => status.value === "ready");
const isFailure = computed<boolean>(() => status.value === "failure");
const filename = computed<string>(() => {
if (null === exportGeneration.value) {
return "";
}
return `${exportGeneration.value?.storedObject.title}-${exportGeneration.value?.createdAt?.datetime8601}`;
});
const externalDownloadLink = computed<string>(
() => `/fr/main/export-generation/${exportGeneration.value?.id}/wait`,
);
const classes = computed<Record<string, boolean>>(() => {
return {};
});
const buttonClasses = computed<Record<string, boolean>>(() => {
return { btn: true, "btn-outline-primary": true };
});
/**
* counter for the number of times that we check for a new status
*/
let tryiesForReady = ref<number>(0);
/**
* how many times we may check for a new status, once loaded
*/
const maxTryiesForReady = 120;
const checkForReady = function (): void {
if (
"ready" === status.value ||
"empty" === status.value ||
"failure" === status.value ||
// stop reloading if the page stays opened for a long time
tryiesForReady.value > maxTryiesForReady
) {
return;
}
tryiesForReady.value = tryiesForReady.value + 1;
setTimeout(
onObjectNewStatusCallback,
tryiesForReady.value < 10 ? 1500 : 5000,
);
};
const onExportGenerationSuccess = function (): void {
toast.success(trans(EXPORT_GENERATION_EXPORT_READY));
};
const onObjectNewStatusCallback = async function (): Promise<void> {
if (null === exportGeneration.value) {
checkForReady();
return Promise.resolve();
}
const newExportGeneration = await fetchExportGenerationStatus(
exportGeneration.value?.id,
);
if (newExportGeneration.status !== exportGeneration.value.status) {
if (newExportGeneration.status === "ready") {
onExportGenerationSuccess();
}
}
exportGeneration.value = newExportGeneration;
if (isPending.value) {
checkForReady();
return Promise.resolve();
}
return Promise.resolve();
};
const onClickGenerate = async (): Promise<void> => {
emits("generate");
exportGeneration.value = await generateFromSavedExport(
props.savedExportUuid,
);
onObjectNewStatusCallback();
return Promise.resolve();
};
</script>
<template>
<button
v-if="isInactive"
:class="buttonClasses"
type="button"
@click="onClickGenerate"
>
<i class="fa fa-cog"></i> {{ trans(SAVED_EXPORT_EXECUTE) }}
</button>
<template v-if="isPending && isFetching">
<span class="btn">
<i class="fa fa-cog fa-spin fa-fw"></i>
<span class="pending-message">{{
trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING_SHORT)
}}</span>
<a :href="externalDownloadLink" class="externalDownloadLink">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</span>
</template>
<div v-if="isPending && !isFetching" :class="buttonClasses">
<span>{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}</span>
</div>
<download-button
v-else-if="isReady && storedObject?.currentVersion !== null"
:classes="buttonClasses"
:stored-object="storedObject"
:at-version="storedObject?.currentVersion"
:filename="filename"
></download-button>
<div v-else-if="isFailure" :class="classes">
<span class="btn">
<i class="bi bi-exclamation-triangle"></i>
<span class="pending-message">{{
trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT)
}}</span>
</span>
</div>
</template>
<style scoped lang="scss">
.pending-message {
font-style: italic;
}
.externalDownloadLink {
margin-left: 1rem;
}
</style>

View File

@@ -0,0 +1,13 @@
import { createApp } from "vue";
import App from "./App.vue";
const buttons = document.querySelectorAll<HTMLDivElement>(
"[data-generate-export-button]",
);
buttons.forEach((button) => {
const savedExportUuid = button.dataset.savedExportUuid as string;
createApp(App, { savedExportUuid, savedExportAlias: "" }).mount(button);
});

View File

@@ -446,11 +446,31 @@ Toutes les classes btn-* de bootstrap sont fonctionnelles
</div>
<div class="row">
<h1>Badges</h1>
<h1>Entity badges</h1>
<span class="badge-accompanying-work-type-simple">Action d'accompagnement</span>
<span class="badge-activity-type-simple">Type d'échange</span>
<span class="badge-calendar-simple">Rendez-vous</span>
</div>
<h1>Badges</h1>
<p>
<span class="badge bg-primary">Primary</span>
<span class="badge bg-secondary">Secondary</span>
<span class="badge bg-success">Success</span>
<span class="badge bg-danger">Danger</span>
<span class="badge bg-warning">Warning</span>
<span class="badge bg-info">Info</span>
<span class="badge bg-light">Light</span>
<span class="badge bg-dark">Dark</span>
<span class="badge bg-chill-blue">chill-blue</span>
<span class="badge bg-chill-green">chill-green</span>
<span class="badge bg-chill-yellow">chill-yellow</span>
<span class="badge bg-chill-orange">chill-orange</span>
<span class="badge bg-chill-red">chill-red</span>
<span class="badge bg-chill-beige">chill-beige</span>
</p>
{% endblock %}

View File

@@ -1,12 +1,14 @@
<ul class="nav nav-pills justify-content-center">
<li class="nav-item">
<a href="{{ chill_path_forward_return_path('chill_main_export_index') }}" class="nav-link {% if current == 'common' %}active{% endif %}">
{{ 'Exports list'|trans }}
</a>
</li>
{% if is_granted('CHILL_MAIN_COMPOSE_EXPORT') %}
<li class="nav-item">
<a href="{{ chill_path_forward_return_path('chill_main_export_index') }}" class="nav-link {% if current == 'common' %}active{% endif %}">
{{ 'Exports list'|trans }}
</a>
</li>
{% endif %}
<li class="nav-item">
<a href="{{ chill_path_forward_return_path('chill_main_export_saved_list_my') }}" class="nav-link {% if current == 'my' %}active{% endif %}">
{{ 'saved_export.My saved exports'|trans }}
{{ 'saved_export.Saved exports'|trans }}
</a>
</li>
</ul>
</ul>

View File

@@ -1,5 +1,5 @@
{#
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
<info@champs-libres.coop> / <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
@@ -20,6 +20,49 @@
{% block title %}{{ 'Exports list'|trans }}{% endblock %}
{% block css %}
{{ parent() }}
<style lang="css">
.export-title {
margin-top: 2rem;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% macro render_export_card(export, export_alias, generations) %}
<div class="col">
<div class="card h-100">
<div class="card-body">
<h2 class="card-title">{{ export.title|trans }}</h2>
<p class="card-text my-3">{{ export.description|trans }}</p>
</div>
{% if generations|length > 0 %}
<ul class="list-group list-group-flush">
{% for generation in generations %}
<li class="list-group-item">
<a href="{{ chill_path_add_return_path('chill_main_export-generation_wait', {'id': generation.id}) }}">{{ generation.createdAt|format_datetime('short', 'short') }}</a>
{% if generation.status == 'pending' %}
&nbsp;<span class="badge bg-info">{{ 'export.generation.Export generation is pending_short'|trans }}</span>
{% elseif generation.status == 'failure' %}
&nbsp;<span class="badge bg-warning">{{ 'export.generation.Error_short'|trans }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<div class="card-footer">
<ul class="record_actions slim">
<li>
<a class="btn btn-submit" href="{{ path('chill_main_export_new', { 'alias': export_alias } ) }}">
{{ 'Create an export'|trans }}
</a>
</li>
</ul>
</div>
</div>
</div>
{% endmacro %}
{% block content %}
@@ -28,45 +71,23 @@
<div class="col-md-10 exports-list">
<div class="container mt-4">
{% for group, exports in grouped_exports %}{% if group != '_' %}
<h2 class="display-6">{{ group|trans }}</h2>
<div class="row flex-bloc">
<h1 class="display-6 export-title">{{ group|trans }}</h1>
<div class="row row-cols-1 row-cols-md-3 g-2">
{% for export_alias, export in exports %}
<div class="item-bloc">
<div class="item-row card-body">
<h2 class="card-title">{{ export.title|trans }}</h2>
<p class="card-text my-3">{{ export.description|trans }}</p>
<p>
<a class="btn btn-action" href="{{ path('chill_main_export_new', { 'alias': export_alias } ) }}">
{{ 'Create an export'|trans }}
</a>
</p>
</div>
</div>
{{ _self.render_export_card(export, export_alias, last_executions[export_alias]) }}
{% endfor %}
</div>
{% endif %}{% endfor %}
{% if grouped_exports|keys|length > 1 and grouped_exports['_']|length > 0 %}
<h2 class="display-6">{{ 'Ungrouped exports'|trans }}</h2>
<h2 class="display-6 export-title">{{ 'Ungrouped exports'|trans }}</h2>
{% endif %}
<div class="row flex-bloc">
<div class="row row-cols-1 row-cols-md-3 g-2">
{% for export_alias,export in grouped_exports['_'] %}
<div class="item-bloc">
<div class="item-row card-body">
<h2 class="card-title">{{ export.title|trans }}</h2>
<p class="card-text my-3">{{ export.description|trans }}</p>
<p>
<a class="btn btn-action" href="{{ path('chill_main_export_new', { 'alias': export_alias } ) }}">
{{ 'Create an export'|trans }}
</a>
</p>
</div>
</div>
{{ _self.render_export_card(export, export_alias, last_executions[export_alias]) }}
{% endfor %}
</div>
</div>

View File

@@ -40,15 +40,15 @@
<h3 class="m-3">{{ 'Center'|trans }}</h3>
{{ form_widget(form.centers.center) }}
{{ form_widget(form.centers.centers) }}
<div class="mb-3 mt-3">
<input id="toggle-check-all" class="btn btn-misc" type= "button" onclick='uncheckAll(this)' value="{{ 'uncheck all centers'|trans|e('html_attr') }}"/>
</div>
{% if form.centers.regroupment is defined %}
{% if form.centers.regroupments is defined %}
<h3 class="m-3">{{ 'Pick aggregated centers'|trans }}</h3>
{{ form_widget(form.centers.regroupment) }}
{{ form_widget(form.centers.regroupments) }}
{% endif %}
</section>

View File

@@ -0,0 +1,61 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('page_download_exports') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('page_download_exports') }}
{% endblock %}
{% block title exportGeneration.linkedToSavedExport ? exportGeneration.savedExport.title : ('Download export'|trans) %}
{% block content %}
<h1>{{ block('title') }}</h1>
<div id="app"
data-export-generation-id="{{ exportGeneration.id | escape('html_attr') }}"
data-export-generation-date="{{ exportGeneration.createdAt.format('Ymd-His') }}"
data-export-title="{{ export.title|trans }}"
></div>
<ul class="sticky-form-buttons record_actions">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_export_saved_list_my') }}" class="btn btn-cancel">
{{ 'export.generation.Come back later'|trans|chill_return_path_label }}
</a>
</li>
{% if not exportGeneration.linkedToSavedExport %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_export_saved_create_from_export_generation', {'id': exportGeneration.id}) }}" class="btn btn-save">
{{ 'Save'|trans }}
</a>
</li>
{% else %}
{% if exportGeneration.configurationDifferentFromSavedExport %}
<li>
<div class="dropdown">
<button class="btn btn-save dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">{{ 'Save'|trans }}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li class="dropdown-item">
<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">
<form method="POST" action="{{ path('chill_main_export_saved_options_edit', {'savedExport': exportGeneration.savedExport.id, 'exportGeneration': exportGeneration.id }) }}">
<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 %}
</ul>
{% endblock content %}

View File

@@ -2,6 +2,16 @@
{% block title %}{{ 'saved_export.Edit'|trans }}{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block content %}
<div class="col-10">
<h1>{{ block('title') }}</h1>
@@ -10,9 +20,13 @@
{{ form_row(form.title) }}
{{ form_row(form.description) }}
{% if form.share is defined %}
{{ form_row(form.share) }}
{% endif %}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel">{{ 'Cancel'|trans }}</a>
<a href="{{ chill_return_path_or('chill_main_export_saved_list_my') }}" class="btn btn-cancel">{{ 'Cancel'|trans }}</a>
</li>
<li>
<button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
@@ -20,4 +34,4 @@
</ul>
{{ form_end(form) }}
</div>
{% endblock %}
{% endblock %}

View File

@@ -1,6 +1,88 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title %}{{ 'saved_export.My saved exports'|trans }}{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_saved_export_button') }}
<style lang="css">
.export-title {
margin-top: 2rem;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_saved_export_button') }}
{% endblock %}
{% block title %}{{ 'saved_export.Saved exports'|trans }}{% endblock %}
{% macro render_export_card(saved, export, export_alias, generations) %}
<div class="col">
<div class="card h-100">
<div class="card-header">
{{ export.title|trans }}
</div>
<div class="card-body">
<h2 class="card-title">{{ saved.title }}</h2>
{% if app.user is same as saved.user %}
<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 saved.isShared() %}<span class="badge bg-info">{{ 'saved_export.Shared with others'|trans }}</span>{% endif %}
</p>
{% else %}
<p class="card-text tags">
Partagé par <span class="badge-user">{{ saved.user|chill_entity_render_box }}</span>
</p>
{% endif %}
<p class="card-text my-3">{{ saved.description|chill_markdown_to_html }}</p>
</div>
{% if generations|length > 0 %}
<ul class="list-group list-group-flush">
{% for generation in generations %}
<li class="list-group-item">
<a href="{{ chill_path_add_return_path('chill_main_export-generation_wait', {'id': generation.id}) }}">{{ generation.createdAt|format_datetime('short', 'short') }}</a>
{% if generation.status == 'pending' %}
&nbsp;<span class="badge bg-info">{{ 'export.generation.Export generation is pending_short'|trans }}</span>
{% elseif generation.status == 'failure' %}
&nbsp;<span class="badge bg-warning">{{ 'export.generation.Error_short'|trans }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<div class="card-footer">
<ul class="record_actions slim">
<li>
<div class="" data-generate-export-button data-saved-export-uuid="{{ saved.id|escape('html_attr') }}"></div>
</li>
<li>
<div class="dropdown">
<button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'Actions'|trans }}
</button>
<ul class="dropdown-menu">
{% if is_granted('CHILL_MAIN_EXPORT_SAVED_EDIT', saved) %}
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_edit', {'id': saved.id }) }}" class="dropdown-item"><i class="fa fa-pencil"></i> {{ 'saved_export.update_title_and_description'|trans }}</a></li>
{% endif %}
{# 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>
{% 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) %}
<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 %}
</ul>
</div>
</li>
</ul>
</div>
</div>
</div>
{% endmacro %}
{% block content %}
<div class="col-md-10 exports-list">
@@ -8,6 +90,7 @@
{{ include('@ChillMain/Export/_navbar.html.twig', {'current' : 'my'}) }}
<div class="container mt-4">
{{ filter|chill_render_filter_order_helper }}
{% if total == 0 %}
<p class="chill-no-data-statement" >{{ 'saved_export.Any saved export'|trans }}</p>
@@ -15,71 +98,23 @@
{% for group, saveds in grouped_exports %}
{% if group != '_' %}
<h2 class="display-6">{{ group }}</h2>
<div class="row flex-bloc">
<h1 class="display-6 export-title">{{ group }}</h1>
<div class="row row-cols-1 row-cols-md-3 g-2">
{% for s in saveds %}
<div class="item-bloc">
<div class="item-row card-body">
<p class="card-subtitle"><strong>{{ s.export.title|trans }}</strong></p>
<h2 class="card-title">{{ s.saved.title }}</h2>
<div class="createdBy">{{ 'saved_export.Created on %date%'|trans({'%date%': s.saved.createdAt|format_datetime('long', 'short')}) }}</div>
<div class="card-text my-3">
{{ s.saved.description|chill_markdown_to_html }}
</div>
<div>
<ul class="record_actions">
<li>
<div class="dropdown">
<button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'Actions'|trans }}
</button>
<ul class="dropdown-menu">
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_edit', {'id': s.saved.id }) }}" class="dropdown-item"><i class="fa fa-pencil"></i> {{ 'saved_export.update_title_and_description'|trans }}</a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_new', {'alias': s.saved.exportAlias,'from_saved': s.saved.id }) }}" class="dropdown-item"><i class="fa fa-pencil"></i> {{ 'saved_export.update_filters_aggregators_and_execute'|trans }}</a></li>
<li><a href="{{ path('chill_main_export_generate_from_saved', { id: s.saved.id }) }}" class="dropdown-item"><i class="fa fa-cog"></i> {{ 'saved_export.execute'|trans }}</a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': s.saved.id }) }}" class="dropdown-item"><i class="fa fa-trash"></i> {{ 'Delete'|trans }}</a></li>
</ul>
</div>
</li>
</ul>
</div>
</div>
</div>
{{ _self.render_export_card(s.saved, s.export, s.saved.exportAlias, last_executions[s.saved.id.toString()]) }}
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% if grouped_exports|keys|length > 1 and grouped_exports['_']|default([])|length > 0 %}
<h2 class="display-6">{{ 'Ungrouped exports'|trans }}</h2>
<h2 class="display-6 export-title">{{ 'Ungrouped exports'|trans }}</h2>
{% endif %}
<div class="row flex-bloc">
{% for saveds in grouped_exports['_']|default([]) %}
{% for s in saveds %}
<div class="item-bloc">
<div class="item-row card-body">
<p class="card-subtitle"><strong>{{ s.export.title|trans }}</strong></p>
<h2 class="card-title">{{ s.saved.title }}</h2>
<div class="createdBy">{{ 'saved_export.Created on %date%'|trans({'%date%': s.saved.createdAt|format_datetime('long', 'short')}) }}</div>
<div class="card-text my-3">
{{ s.saved.description|chill_markdown_to_html }}
</div>
<div>
<ul class="record_actions">
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': s.saved.id }) }}" class="btn btn-delete"></a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_edit', {'id': s.saved.id }) }}" class="btn btn-edit"></a></li>
<li><a href="{{ path('chill_main_export_generate_from_saved', { id: s.saved.id }) }}" class="btn btn-action"><i class="fa fa-cog"></i></a></li>
</ul>
</div>
</div>
</div>
{{ _self.render_export_card(s.saved, s.export, s.saved.exportAlias, last_executions[s.saved.id.toString()]) }}
{% endfor %}
{% endfor %}
</div>

View File

@@ -2,14 +2,35 @@
{% block title %}{{ 'saved_export.New'|trans }}{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block content %}
<div class="col-10">
<h1>{{ block('title') }}</h1>
{{ form_start(form) }}
{{ 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) }}
{% if form.share is defined %}
{{ form_row(form.share) }}
{% endif %}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel">{{ 'Cancel'|trans }}</a>
@@ -20,4 +41,4 @@
</ul>
{{ form_end(form) }}
</div>
{% endblock %}
{% endblock %}

View File

@@ -10,19 +10,19 @@
{% for flashMessage in app.session.flashbag.get('success') %}
<div class="col-8 alert alert-success flash_message">
<span>{{ flashMessage|raw }}</span>
<span>{{ flashMessage|trans }}</span>
</div>
{% endfor %}
{% for flashMessage in app.session.flashbag.get('error') %}
<div class="col-8 alert alert-danger flash_message">
<span>{{ flashMessage|raw }}</span>
<span>{{ flashMessage|trans }}</span>
</div>
{% endfor %}
{% for flashMessage in app.session.flashbag.get('notice') %}
<div class="col-8 alert alert-warning flash_message">
<span>{{ flashMessage|raw }}</span>
<span>{{ flashMessage|trans }}</span>
</div>
{% endfor %}

View File

@@ -49,9 +49,9 @@ class SectionMenuBuilder implements LocalMenuBuilderInterface
);
}
if ($this->authorizationChecker->isGranted(ChillExportVoter::EXPORT)) {
if ($this->authorizationChecker->isGranted(ChillExportVoter::GENERATE_SAVED_EXPORT)) {
$menu->addChild($this->translator->trans('Export Menu'), [
'route' => 'chill_main_export_index',
'route' => 'chill_main_export_saved_list_my',
])
->setExtras([
'icons' => ['upload'],

View File

@@ -11,12 +11,21 @@ declare(strict_types=1);
namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ChillExportVoter extends Voter
class ChillExportVoter extends Voter implements ProvideRoleHierarchyInterface
{
final public const EXPORT = 'chill_export';
/**
* Role which give access to the creation of new export from the export itself.
*/
final public const COMPOSE_EXPORT = 'CHILL_MAIN_COMPOSE_EXPORT';
/**
* Role which give access to the execution and edition to the saved exports, but not for creating new ones.
*/
final public const GENERATE_SAVED_EXPORT = 'CHILL_MAIN_GENERATE_SAVED_EXPORT';
private readonly VoterHelperInterface $helper;
@@ -24,7 +33,7 @@ class ChillExportVoter extends Voter
{
$this->helper = $voterHelperFactory
->generate(self::class)
->addCheckFor(null, [self::EXPORT])
->addCheckFor(null, [self::COMPOSE_EXPORT, self::GENERATE_SAVED_EXPORT])
->build();
}
@@ -37,4 +46,21 @@ class ChillExportVoter extends Voter
{
return $this->helper->voteOnAttribute($attribute, $subject, $token);
}
public function getRolesWithHierarchy(): array
{
return ['export.role.export_role' => [
self::COMPOSE_EXPORT, self::GENERATE_SAVED_EXPORT,
]];
}
public function getRoles(): array
{
return [self::COMPOSE_EXPORT, self::GENERATE_SAVED_EXPORT];
}
public function getRolesWithoutScope(): array
{
return $this->getRoles();
}
}

View File

@@ -0,0 +1,32 @@
<?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\Security\Authorization;
use Chill\MainBundle\Entity\ExportGeneration;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ExportGenerationVoter extends Voter
{
public const VIEW = 'view';
protected function supports(string $attribute, $subject)
{
return self::VIEW === $attribute && $subject instanceof ExportGeneration;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token)
{
/* @var ExportGeneration $subject */
return $token->getUser()->getUserIdentifier() === $subject->getCreatedBy()->getUserIdentifier();
}
}

View File

@@ -12,23 +12,37 @@ declare(strict_types=1);
namespace Chill\MainBundle\Security\Authorization;
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;
class SavedExportVoter extends Voter
final class SavedExportVoter extends Voter
{
final public const DELETE = 'CHLL_MAIN_EXPORT_SAVED_DELETE';
final public const DELETE = 'CHILL_MAIN_EXPORT_SAVED_DELETE';
final public const EDIT = 'CHLL_MAIN_EXPORT_SAVED_EDIT';
final public const EDIT = 'CHILL_MAIN_EXPORT_SAVED_EDIT';
final public const GENERATE = 'CHLL_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';
private const ALL = [
self::DELETE,
self::EDIT,
self::GENERATE,
self::SHARE,
self::DUPLICATE,
];
public function __construct(
private readonly ExportManager $exportManager,
private readonly AccessDecisionManagerInterface $accessDecisionManager,
) {}
protected function supports($attribute, $subject): bool
{
return $subject instanceof SavedExport && \in_array($attribute, self::ALL, true);
@@ -36,9 +50,30 @@ class SavedExportVoter extends Voter
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
/* @var SavedExport $subject */
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return match ($attribute) {
self::DELETE, self::EDIT, self::GENERATE => $subject->getUser() === $token->getUser(),
self::DELETE, self::EDIT => $subject->getUser() === $token->getUser(),
self::SHARE => $subject->getUser() === $token->getUser() && $this->accessDecisionManager->decide($token, [ChillExportVoter::COMPOSE_EXPORT]),
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),
};
}
private function canUserGenerate(User $user, SavedExport $savedExport): bool
{
if (!($savedExport->getUser() === $user || $savedExport->isSharedWithUser($user))) {
return false;
}
$export = $this->exportManager->getExport($savedExport->getExportAlias());
return $this->exportManager->isGrantedForElement($export);
}
}

View File

@@ -0,0 +1,43 @@
<?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\Security\Authorization\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Security\Authorization\ExportGenerationVoter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
final readonly class ExportGenerationStoredObjectVoter implements StoredObjectVoterInterface
{
public function __construct(private ExportGenerationRepository $repository, private Security $security) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
{
return null !== $this->repository->findAssociatedEntityToStoredObject($subject);
}
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{
if (StoredObjectRoleEnum::EDIT === $attribute) {
return false;
}
if (null === $generation = $this->repository->findAssociatedEntityToStoredObject($subject)) {
throw new \UnexpectedValueException('generation not found');
}
return $this->security->isGranted(ExportGenerationVoter::VIEW, $generation);
}
}

View File

@@ -0,0 +1,44 @@
<?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\Service\Regroupement;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Regroupment;
class CenterRegroupementResolver
{
/**
* Resolves and returns a unique list of centers by merging those from the provided
* groups and the additional centers while eliminating duplicates.
*
* @param list<Regroupment> $groups
* @param list<Center> $centers
*
* @return list<Center>
*/
public function resolveCenters(array $groups, array $centers = []): array
{
$centersByHash = [];
foreach ($groups as $group) {
foreach ($group->getCenters() as $center) {
$centersByHash[spl_object_hash($center)] = $center;
}
}
foreach ($centers as $center) {
$centersByHash[spl_object_hash($center)] = $center;
}
return array_values($centersByHash);
}
}

View File

@@ -0,0 +1,37 @@
<?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\Service\Regroupement;
use Chill\MainBundle\Entity\Regroupment;
/**
* Class RegroupementFiltering.
*
* Provides methods to filter and manage groups based on specific criteria.
*/
class RegroupementFiltering
{
/**
* Filters the provided groups and returns only those that contain at least one of the specified centers.
*
* @param array $groups an array of groups to filter
* @param array $centers an array of centers to check against the groups
*
* @return array an array of filtered groups containing at least one of the specified centers
*/
public function filterContainsAtLeastOneCenter(array $groups, array $centers): array
{
return array_values(
array_filter($groups, static fn (Regroupment $group) => $group->containsAtLeastOneCenter($centers)),
);
}
}

Some files were not shown because too many files have changed in this diff Show More