First step to async generation [WIP]

This commit is contained in:
2025-02-20 14:33:50 +01:00
parent 732b7dc8f7
commit 057c34610d
20 changed files with 489 additions and 41 deletions

View File

@@ -11,22 +11,26 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\DirectExportInterface;
use Chill\MainBundle\Export\ExportFormHelper;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage;
use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FormatterType;
use Chill\MainBundle\Form\Type\Export\PickCenterType;
use Chill\MainBundle\Messenger\Stamp\AuthenticationStamp;
use Chill\MainBundle\Redis\ChillRedis;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@@ -37,6 +41,8 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -61,6 +67,8 @@ class ExportController extends AbstractController
private readonly SavedExportRepositoryInterface $savedExportRepository,
private readonly Security $security,
ParameterBagInterface $parameterBag,
private readonly MessageBusInterface $messageBus,
private readonly ClockInterface $clock,
) {
$this->filterStatsByCenters = $parameterBag->get('chill_main')['acl']['filter_stats_by_center'];
}
@@ -128,22 +136,21 @@ class ExportController extends AbstractController
* @throws \RedisException
*/
#[Route(path: '/{_locale}/exports/generate-from-saved/{id}', name: 'chill_main_export_generate_from_saved')]
public function generateFromSavedExport(SavedExport $savedExport): RedirectResponse
public function generateFromSavedExport(SavedExport $savedExport): Response
{
$this->denyAccessUnlessGranted(SavedExportVoter::GENERATE, $savedExport);
$key = md5(uniqid((string) random_int(0, mt_getrandmax()), false));
$exportGeneration = ExportGeneration::fromSavedExport($savedExport, $this->clock->now()->add(new \DateInterval('P3M')));
$this->entityManager->persist($exportGeneration);
$this->entityManager->flush();
$this->redis->setEx($key, 3600, \serialize($savedExport->getOptions()));
$this->messageBus->dispatch(
new Envelope(
new ExportRequestGenerationMessage($exportGeneration),
[new AuthenticationStamp($this->security->getUser())]
));
return $this->redirectToRoute(
'chill_main_export_download',
[
'alias' => $savedExport->getExportAlias(),
'key' => $key, 'prevent_save' => true,
'returnPath' => $this->generateUrl('chill_main_export_saved_list_my'),
]
);
return new Response('Ok: '.$exportGeneration->getId()->toString());
}
/**

View File

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

View File

@@ -11,7 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\FilterType;
use Chill\MainBundle\Form\Type\Export\FormatterType;
@@ -91,7 +91,7 @@ final readonly class ExportFormHelper
}
public function savedExportDataToFormData(
SavedExport $savedExport,
ExportGeneration $savedExport,
string $step,
array $formOptions = [],
): array {
@@ -104,7 +104,7 @@ final readonly class ExportFormHelper
}
private function savedExportDataToFormDataStepCenter(
SavedExport $savedExport,
ExportGeneration $savedExport,
): array {
$builder = $this->formFactory
->createBuilder(
@@ -125,7 +125,7 @@ final readonly class ExportFormHelper
}
private function savedExportDataToFormDataStepExport(
SavedExport $savedExport,
ExportGeneration $savedExport,
array $formOptions,
): array {
$builder = $this->formFactory
@@ -147,7 +147,7 @@ final readonly class ExportFormHelper
}
private function savedExportDataToFormDataStepFormatter(
SavedExport $savedExport,
ExportGeneration $savedExport,
array $formOptions,
): array {
$builder = $this->formFactory

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Entity\User;
class ExportGenerationContext
{
public function __construct(
public readonly User $byUser,
) {}
}

View File

@@ -0,0 +1,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;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\User;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
final readonly class ExportGenerator
{
public function __construct(
private ExportManager $exportManager,
private StoredObjectManagerInterface $storedObjectManager,
private EntityManagerInterface $entityManager,
private ExportFormHelper $exportFormHelper,
) {}
public function generate(ExportGeneration $exportGeneration, User $user): void
{
$this->entityManager->wrapInTransaction(function () use ($exportGeneration) {
$object = $exportGeneration->getStoredObject();
$this->entityManager->refresh($exportGeneration, LockMode::PESSIMISTIC_WRITE);
$this->entityManager->refresh($object, LockMode::PESSIMISTIC_WRITE);
if (StoredObject::STATUS_PENDING !== $object->getStatus()) {
return;
}
$generation = $this->exportManager->generateExport(
$exportGeneration->getExportAlias(),
$centers = $this->exportFormHelper->savedExportDataToFormData($exportGeneration, 'centers'),
$this->exportFormHelper->savedExportDataToFormData($exportGeneration, 'export', ['picked_centers' => $centers]),
$this->exportFormHelper->savedExportDataToFormData($exportGeneration, 'formatter', ['picked_centers' => $centers]),
$user,
);
$this->storedObjectManager->write($exportGeneration->getStoredObject(), $generation->content, $generation->contentType);
});
}
}

View File

@@ -165,25 +165,31 @@ 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
public function generateExport(string $exportAlias, array $pickedCentersData, array $data, array $formatterData, User $byUser): FormattedExportGeneration
{
$export = $this->getExport($exportAlias);
$centers = $this->getPickedCenters($pickedCentersData);
$context = new ExportGenerationContext($byUser);
if ($export instanceof DirectExportInterface) {
return $export->generate(
$generatedExport = $export->generate(
$this->buildCenterReachableScopes($centers, $export),
$data[ExportType::EXPORT_KEY]
$data[ExportType::EXPORT_KEY],
);
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, $export),
$data[ExportType::EXPORT_KEY]
$export->denormalizeFormData($data[ExportType::EXPORT_KEY], $context),
);
if ($query instanceof \Doctrine\ORM\NativeQuery) {
@@ -194,10 +200,10 @@ class ExportManager
}
} elseif ($query instanceof QueryBuilder) {
// handle filters
$this->handleFilters($export, $query, $data[ExportType::FILTER_KEY], $centers);
$this->handleFilters($export, $query, $data[ExportType::FILTER_KEY], $centers, $context);
// handle aggregators
$this->handleAggregators($export, $query, $data[ExportType::AGGREGATOR_KEY], $centers);
$this->handleAggregators($export, $query, $data[ExportType::AGGREGATOR_KEY], $centers, $context);
$this->logger->notice('[export] will execute this qb in export', [
'dql' => $query->getDQL(),
@@ -206,7 +212,7 @@ class ExportManager
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]);
$result = $export->getResult($query, $export->denormalizeFormData($data[ExportType::EXPORT_KEY], $context));
if (!is_iterable($result)) {
throw new \UnexpectedValueException(sprintf('The result of the export should be an iterable, %s given', \gettype($result)));
@@ -231,14 +237,44 @@ class ExportManager
$filtersData[$alias] = $data[ExportType::FILTER_KEY][$alias]['form'];
}
return $formatter->getResponse(
if (method_exists($formatter, 'generate')) {
return $formatter->generate(
$result,
$formatterData,
$exportAlias,
$data[ExportType::EXPORT_KEY],
$filtersData,
$aggregatorsData,
);
}
trigger_deprecation('chill-project/chill-bundles', '3.10', '%s should implements the "generate" method', FormatterInterface::class);
$generatedExport = $formatter->getResponse(
$result,
$formatterData,
$exportAlias,
$data[ExportType::EXPORT_KEY],
$filtersData,
$aggregatorsData
$aggregatorsData,
);
return new FormattedExportGeneration($generatedExport->getContent(), $generatedExport->headers->get('content-type'));
}
/**
* Generate a response which contains the requested data.
*/
public function generate(string $exportAlias, array $pickedCentersData, array $data, array $formatterData): Response
{
$generated = $this->generateExport(
$exportAlias,
$pickedCentersData,
$data,
$formatterData,
);
return new Response($generated->content, headers: ['Content-Type' => $generated->contentType]);
}
/**
@@ -453,6 +489,7 @@ class ExportManager
DirectExportInterface|ExportInterface|null $export = null,
?array $centers = null,
): bool {
dump(__METHOD__, $this->tokenStorage->getToken()->getUser());
if ($element instanceof ExportInterface || $element instanceof DirectExportInterface) {
$role = $element->requiredRole();
} else {
@@ -473,7 +510,7 @@ class ExportManager
$role
);
}
dump($centers);
foreach ($centers as $center) {
if (false === $this->authorizationChecker->isGranted($role, $center)) {
// debugging
@@ -534,16 +571,13 @@ class ExportManager
QueryBuilder $qb,
array $data,
array $center,
ExportGenerationContext $context,
) {
$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']);
$aggregator->alterQuery($qb, $aggregator->denormalizeFormData($formData['form'], $context));
}
}
@@ -561,20 +595,17 @@ class ExportManager
QueryBuilder $qb,
mixed $data,
array $centers,
ExportGenerationContext $context,
) {
$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']);
$filter->alterQuery($qb, $filter->denormalizeFormData($formData['form'], $context));
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export;
final readonly class FormattedExportGeneration
{
public function __construct(
public string $content,
public string $contentType,
) {}
}

View File

@@ -13,6 +13,9 @@ namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @method generate($result, $formatterData, string $exportAlias, array $exportData, array $filtersData, array $aggregatorsData): FormattedExportGeneration
*/
interface FormatterInterface
{
public const TYPE_LIST = 'list';

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Export\Messenger;
use Chill\MainBundle\Entity\ExportGeneration;
use Chill\MainBundle\Entity\User;
use Ramsey\Uuid\UuidInterface;
final readonly class ExportRequestGenerationMessage
{
public UuidInterface $id;
public int $userId;
public function __construct(
ExportGeneration $exportGeneration,
User $user,
) {
$this->id = $exportGeneration->getId();
$this->userId = $user->getId();
}
}

View File

@@ -0,0 +1,41 @@
<?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\Export\ExportGenerator;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
#[AsMessageHandler]
final readonly class ExportRequestGenerationMessageHandler implements MessageHandlerInterface
{
public function __construct(
private ExportGenerationRepository $repository,
private UserRepositoryInterface $userRepository,
private ExportGenerator $exportGenerator,
) {}
public function __invoke(ExportRequestGenerationMessage $exportRequestGenerationMessage)
{
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');
}
$this->exportGenerator->generate($exportGeneration, $user);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Chill\MainBundle\Messenger\Authentication;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
use Symfony\Component\Security\Core\User\UserInterface;
class AuthenticatedMessengerToken extends AbstractToken
{
public function __construct(UserInterface $user, array $roles = [])
{
parent::__construct($roles);
$this->setUser($user);
$this->setAuthenticated(true);
}
public function getCredentials(): null
{
return null;
}
}

View File

@@ -0,0 +1,47 @@
<?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\Messenger\Middleware;
use Chill\MainBundle\Messenger\Authentication\AuthenticatedMessengerToken;
use Chill\MainBundle\Messenger\Stamp\AuthenticationStamp;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
final readonly class AuthenticationMiddleware implements MiddlewareInterface
{
public function __construct(
private TokenStorageInterface $tokenStorage,
private UserProviderInterface $userProvider,
) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
dump(__METHOD__);
if (null !== $authenticationStamp = $envelope->last(AuthenticationStamp::class)) {
return;
/** @var AuthenticationStamp $authenticationStamp */
dump("authenticate user", $authenticationStamp->getUserId());
if (null !== $this->tokenStorage->getToken()) {
dump("token already present");
} else {
$user = $this->userProvider->loadUserByUsername($authenticationStamp->getUserId());
$this->tokenStorage->setToken(new AuthenticatedMessengerToken($user, [...$user->getRoles(), 'IS_AUTHENTICATED_FULLY']));
}
}
return $stack->next()->handle($envelope, $stack);
}
}

View File

@@ -0,0 +1,33 @@
<?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\Messenger\Stamp;
use Symfony\Component\Messenger\Stamp\StampInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Stamp which will add a user as authenticated during the message handling.
*/
final readonly class AuthenticationStamp implements StampInterface
{
private string $userId;
public function __construct(UserInterface $user)
{
$this->userId = $user->getUserIdentifier();
}
public function getUserId(): string
{
return $this->userId;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\ExportGeneration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ExportGeneration>
*/
class ExportGenerationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ExportGeneration::class);
}
}

View File

@@ -101,6 +101,11 @@ services:
autowire: true
autoconfigure: true
Chill\MainBundle\Messenger\:
resource: '../Messenger/'
autowire: true
autoconfigure: true
Chill\MainBundle\Cron\:
resource: '../Cron'
autowire: true

View File

@@ -6,8 +6,12 @@ services:
Chill\MainBundle\Export\Helper\:
resource: '../../Export/Helper'
Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessageHandler: ~
Chill\MainBundle\Export\ExportFormHelper: ~
Chill\MainBundle\Export\ExportGenerator: ~
chill.main.export_element_validator:
class: Chill\MainBundle\Validator\Constraints\Export\ExportElementConstraintValidator
tags:

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Export\Export;
use Chill\MainBundle\Export\AccompanyingCourseExportHelper;
use Chill\MainBundle\Export\ExportGenerationContext;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
@@ -49,6 +50,16 @@ class CountAccompanyingCourse implements ExportInterface, GroupedExportInterface
return [];
}
public function normalizeFormData(array $formData): array
{
return $formData;
}
public function denormalizeFormData(array $formData, ExportGenerationContext $context): array
{
return $formData;
}
public function getAllowedFormattersTypes(): array
{
return [FormatterInterface::TYPE_TABULAR];