mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-10-22 21:22:48 +00:00
Merge branch 'master' into ticket-app-master
# Conflicts: # src/Bundle/ChillMainBundle/Export/Formatter/CSVFormatter.php # src/Bundle/ChillMainBundle/Export/Formatter/CSVListFormatter.php # src/Bundle/ChillMainBundle/Export/Formatter/SpreadsheetListFormatter.php # src/Bundle/ChillMainBundle/Resources/public/vuejs/PickEntity/PickEntity.vue # src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/GeographicalUnitStatAggregator.php # src/Bundle/ChillPersonBundle/Resources/public/types.ts # src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons.vue
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
@@ -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()];
|
||||
}
|
||||
}
|
@@ -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).
|
||||
|
@@ -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 {}
|
@@ -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 {}
|
@@ -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 {}
|
@@ -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);
|
||||
}
|
||||
}
|
128
src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php
Normal file
128
src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php
Normal 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)),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
49
src/Bundle/ChillMainBundle/Export/ExportConfigProcessor.php
Normal file
49
src/Bundle/ChillMainBundle/Export/ExportConfigProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
189
src/Bundle/ChillMainBundle/Export/ExportDataNormalizerTrait.php
Normal file
189
src/Bundle/ChillMainBundle/Export/ExportDataNormalizerTrait.php
Normal 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;
|
||||
}
|
||||
}
|
@@ -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] ?? []);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -21,7 +21,7 @@ namespace Chill\MainBundle\Export;
|
||||
interface ExportElementsProviderInterface
|
||||
{
|
||||
/**
|
||||
* @return ExportElementInterface[]
|
||||
* @return iterable<ExportElementInterface>
|
||||
*/
|
||||
public function getExportElements();
|
||||
public function getExportElements(): iterable;
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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,
|
||||
) {}
|
||||
}
|
273
src/Bundle/ChillMainBundle/Export/ExportGenerator.php
Normal file
273
src/Bundle/ChillMainBundle/Export/ExportGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
@@ -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.
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -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,
|
||||
) {}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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')];
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
|
||||
|
@@ -11,15 +11,20 @@ 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
|
||||
@@ -27,52 +32,23 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
/**
|
||||
* 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 +68,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 +127,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 +138,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 +161,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 +214,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 +246,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)
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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 {}
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user