Julien Fastré d3d98cdec2
Update method parameter type in ExportController
The parameter type for the 'rebuildRawData' function in the ExportController class has been changed to accept null values. This change is introduced to handle cases where a null key might be passed, preventing potential errors in the application.
2024-06-24 11:33:54 +02:00

677 lines
24 KiB
PHP

<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\SavedExport;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\DirectExportInterface;
use Chill\MainBundle\Export\ExportFormHelper;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManager;
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\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\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class ExportController
* Controller used for exporting data.
*/
class ExportController extends AbstractController
{
private readonly bool $filterStatsByCenters;
public function __construct(
private readonly ChillRedis $redis,
private readonly ExportManager $exportManager,
private readonly FormFactoryInterface $formFactory,
private readonly LoggerInterface $logger,
private readonly SessionInterface $session,
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $entityManager,
private readonly ExportFormHelper $exportFormHelper,
private readonly SavedExportRepositoryInterface $savedExportRepository,
private readonly Security $security,
ParameterBagInterface $parameterBag,
) {
$this->filterStatsByCenters = $parameterBag->get('chill_main')['acl']['filter_stats_by_center'];
}
#[Route(path: '/{_locale}/exports/download/{alias}', name: 'chill_main_export_download', methods: ['GET'])]
public function downloadResultAction(Request $request, mixed $alias)
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$export = $exportManager->getExport($alias);
$key = $request->query->get('key', null);
$savedExport = $this->getSavedExportFromRequest($request);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key, $savedExport);
$formatterAlias = $exportManager->getFormatterAlias($dataExport['export']);
if (null !== $formatterAlias) {
$formater = $exportManager->getFormatter($formatterAlias);
} else {
$formater = null;
}
$viewVariables = [
'alias' => $alias,
'export' => $export,
'export_group' => $this->getExportGroup($export),
'saved_export' => $savedExport,
];
if ($formater instanceof \Chill\MainBundle\Export\Formatter\CSVListFormatter) {
// due to a bug in php, we add the mime type in the download view
$viewVariables['mime_type'] = 'text/csv';
}
return $this->render('@ChillMain/Export/download.html.twig', $viewVariables);
}
/**
* Generate a report.
*
* This action must work with GET queries.
*
* @param string $alias
*/
#[Route(path: '/{_locale}/exports/generate/{alias}', name: 'chill_main_export_generate', methods: ['GET'])]
public function generateAction(Request $request, $alias): Response
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$key = $request->query->get('key', null);
$savedExport = $this->getSavedExportFromRequest($request);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key, $savedExport);
return $exportManager->generate(
$alias,
$dataCenters['centers'],
$dataExport['export'],
null !== $dataFormatter ? $dataFormatter['formatter'] : []
);
}
/**
* @throws \RedisException
*/
#[Route(path: '/{_locale}/exports/generate-from-saved/{id}', name: 'chill_main_export_generate_from_saved')]
public function generateFromSavedExport(SavedExport $savedExport): RedirectResponse
{
$this->denyAccessUnlessGranted(SavedExportVoter::GENERATE, $savedExport);
$key = md5(uniqid((string) random_int(0, mt_getrandmax()), false));
$this->redis->setEx($key, 3600, \serialize($savedExport->getOptions()));
return $this->redirectToRoute(
'chill_main_export_download',
[
'alias' => $savedExport->getExportAlias(),
'key' => $key, 'prevent_save' => true,
'returnPath' => $this->generateUrl('chill_main_export_saved_list_my'),
]
);
}
/**
* Render the list of available exports.
*/
#[Route(path: '/{_locale}/exports/', name: 'chill_main_export_index')]
public function indexAction(): Response
{
$exportManager = $this->exportManager;
$exports = $exportManager->getExportsGrouped(true);
return $this->render('@ChillMain/Export/layout.html.twig', [
'grouped_exports' => $exports,
]);
}
/**
* handle the step to build a query for an export.
*
* This action has three steps :
*
* 1.'export', the export form. When the form is posted, the data is stored
* in the session (if valid), and then a redirection is done to next step.
* 2. 'formatter', the formatter form. When the form is posted, the data is
* stored in the session (if valid), and then a redirection is done to next step.
* 3. 'generate': gather data from session from the previous steps, and
* make a redirection to the "generate" action with data in query (HTTP GET)
*/
#[Route(path: '/{_locale}/exports/new/{alias}', name: 'chill_main_export_new')]
public function newAction(Request $request, string $alias): Response
{
// first check for ACL
$exportManager = $this->exportManager;
$export = $exportManager->getExport($alias);
if (false === $exportManager->isGrantedForElement($export)) {
throw $this->createAccessDeniedException('The user does not have access to this export');
}
$savedExport = $this->getSavedExportFromRequest($request);
$step = $request->query->getAlpha('step', 'centers');
return match ($step) {
'centers' => $this->selectCentersStep($request, $export, $alias, $savedExport),
'export' => $this->exportFormStep($request, $export, $alias, $savedExport),
'formatter' => $this->formatterFormStep($request, $export, $alias, $savedExport),
'generate' => $this->forwardToGenerate($request, $export, $alias, $savedExport),
default => throw $this->createNotFoundException("The given step '{$step}' is invalid"),
};
}
#[Route(path: '/{_locale}/export/saved/update-from-key/{id}/{key}', name: 'chill_main_export_saved_edit_options_from_key')]
public function editSavedExportOptionsFromKey(SavedExport $savedExport, string $key): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException();
}
$data = $this->rebuildRawData($key);
$savedExport
->setOptions($data);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_export_saved_edit', ['id' => $savedExport->getId()]);
}
#[Route(path: '/{_locale}/export/save-from-key/{alias}/{key}', name: 'chill_main_export_save_from_key')]
public function saveFromKey(string $alias, string $key, Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException();
}
$data = $this->rebuildRawData($key);
$savedExport = new SavedExport();
$savedExport
->setOptions($data)
->setExportAlias($alias)
->setUser($user);
$form = $this->createForm(SavedExportType::class, $savedExport);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($savedExport);
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_export_index');
}
return $this->render(
'@ChillMain/SavedExport/new.html.twig',
[
'form' => $form->createView(),
'saved_export' => $savedExport,
]
);
}
/**
* create a form to show on different steps.
*
* @param array $data the data from previous step. Required for steps 'formatter' and 'generate_formatter'
*/
protected function createCreateFormExport(string $alias, string $step, array $data, ?SavedExport $savedExport): FormInterface
{
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$isGenerate = str_starts_with($step, 'generate_');
$options = match ($step) {
'export', 'generate_export' => [
'export_alias' => $alias,
'picked_centers' => $exportManager->getPickedCenters($data['centers'] ?? []),
],
'formatter', 'generate_formatter' => [
'export_alias' => $alias,
'formatter_alias' => $exportManager->getFormatterAlias($data['export']),
'aggregator_aliases' => $exportManager->getUsedAggregatorsAliases($data['export']),
],
default => [
'export_alias' => $alias,
],
};
$defaultFormData = match ($savedExport) {
null => $this->exportFormHelper->getDefaultData($step, $exportManager->getExport($alias), $options),
default => $this->exportFormHelper->savedExportDataToFormData($savedExport, $step, $options),
};
$builder = $this->formFactory
->createNamedBuilder(
'',
FormType::class,
$defaultFormData,
[
'method' => $isGenerate ? Request::METHOD_GET : Request::METHOD_POST,
'csrf_protection' => !$isGenerate,
]
);
if ('centers' === $step || 'generate_centers' === $step) {
$builder->add('centers', PickCenterType::class, $options);
}
if ('export' === $step || 'generate_export' === $step) {
$builder->add('export', ExportType::class, $options);
}
if ('formatter' === $step || 'generate_formatter' === $step) {
$builder->add('formatter', FormatterType::class, $options);
}
$builder->add('submit', SubmitType::class, [
'label' => 'Generate',
]);
return $builder->getForm();
}
/**
* Render the export form.
*
* When the method is POST, the form is stored if valid, and a redirection
* is done to next step.
*/
private function exportFormStep(Request $request, DirectExportInterface|ExportInterface $export, string $alias, ?SavedExport $savedExport = null): Response
{
$exportManager = $this->exportManager;
// check we have data from the previous step (export step)
$data = $this->session->get('centers_step', []);
if (null === $data && true === $this->filterStatsByCenters) {
return $this->redirectToRoute('chill_main_export_new', [
'step' => $this->getNextStep('export', $export, true),
'alias' => $alias,
]);
}
$export = $exportManager->getExport($alias);
$form = $this->createCreateFormExport($alias, 'export', $data, $savedExport);
if (Request::METHOD_POST === $request->getMethod()) {
$form->handleRequest($request);
if ($form->isValid()) {
$this->logger->debug('form export is valid', [
'location' => __METHOD__, ]);
// store data for reusing in next steps
$data = $form->getData();
$this->session->set(
'export_step_raw',
$request->request->all()
);
$this->session->set('export_step', $data);
// redirect to next step
return $this->redirectToRoute('chill_main_export_new', [
'step' => $this->getNextStep('export', $export),
'alias' => $alias,
'from_saved' => $request->get('from_saved', ''),
]);
}
$this->logger->debug('form export is invalid', [
'location' => __METHOD__, ]);
}
return $this->render('@ChillMain/Export/new.html.twig', [
'form' => $form->createView(),
'export_alias' => $alias,
'export' => $export,
'export_group' => $this->getExportGroup($export),
]);
}
/**
* Render the form for formatter.
*
* If the form is posted and valid, store the data in session and
* redirect to the next step.
*/
private function formatterFormStep(Request $request, DirectExportInterface|ExportInterface $export, string $alias, ?SavedExport $savedExport = null): Response
{
// check we have data from the previous step (export step)
$data = $this->session->get('export_step', null);
if (null === $data) {
return $this->redirectToRoute('chill_main_export_new', [
'step' => $this->getNextStep('formatter', $export, true),
'alias' => $alias,
]);
}
$form = $this->createCreateFormExport($alias, 'formatter', $data, $savedExport);
if (Request::METHOD_POST === $request->getMethod()) {
$form->handleRequest($request);
if ($form->isValid()) {
$dataFormatter = $form->getData();
$this->session->set('formatter_step', $dataFormatter);
$this->session->set(
'formatter_step_raw',
$request->request->all()
);
// redirect to next step
return $this->redirectToRoute('chill_main_export_new', [
'alias' => $alias,
'step' => $this->getNextStep('formatter', $export),
'from_saved' => $request->get('from_saved', ''),
]);
}
}
return $this->render(
'@ChillMain/Export/new_formatter_step.html.twig',
[
'form' => $form->createView(),
'export' => $export,
'export_group' => $this->getExportGroup($export),
]
);
}
/**
* Gather data stored in session from previous steps, store it inside redis
* and redirect to the `generate` action.
*
* The data from previous steps is removed from session.
*
* @param string $alias
*
* @return RedirectResponse
*/
private function forwardToGenerate(Request $request, DirectExportInterface|ExportInterface $export, $alias, ?SavedExport $savedExport)
{
$dataCenters = $this->session->get('centers_step_raw', null);
$dataFormatter = $this->session->get('formatter_step_raw', null);
$dataExport = $this->session->get('export_step_raw', null);
if (null === $dataFormatter && $export instanceof ExportInterface) {
return $this->redirectToRoute('chill_main_export_new', [
'alias' => $alias,
'step' => $this->getNextStep('generate', $export, true),
'from_saved' => $savedExport?->getId() ?? '',
]);
}
$parameters = [
'formatter' => $dataFormatter ?? [],
'export' => $dataExport ?? [],
'centers' => $dataCenters ?? [],
'alias' => $alias,
];
unset($parameters['_token']);
$key = md5(uniqid((string) random_int(0, mt_getrandmax()), false));
$this->redis->setEx($key, 3600, \serialize($parameters));
// remove data from session
$this->session->remove('export_step_raw');
$this->session->remove('export_step');
$this->session->remove('formatter_step_raw');
$this->session->remove('formatter_step');
return $this->redirectToRoute('chill_main_export_download', [
'key' => $key,
'alias' => $alias,
'from_saved' => $savedExport?->getId(),
]);
}
private function rebuildData($key, ?SavedExport $savedExport)
{
$rawData = $this->rebuildRawData($key);
$alias = $rawData['alias'];
if ($this->filterStatsByCenters) {
$formCenters = $this->createCreateFormExport($alias, 'generate_centers', [], $savedExport);
$formCenters->submit($rawData['centers']);
$dataCenters = $formCenters->getData();
} else {
$dataCenters = ['centers' => []];
}
$formExport = $this->createCreateFormExport($alias, 'generate_export', $dataCenters, $savedExport);
$formExport->submit($rawData['export']);
$dataExport = $formExport->getData();
if (\count($rawData['formatter']) > 0) {
$formFormatter = $this->createCreateFormExport(
$alias,
'generate_formatter',
$dataExport,
$savedExport
);
$formFormatter->submit($rawData['formatter']);
$dataFormatter = $formFormatter->getData();
}
return [$dataCenters, $dataExport, $dataFormatter ?? null];
}
/**
* @param string $alias
*
* @return Response
*/
private function selectCentersStep(Request $request, DirectExportInterface|ExportInterface $export, $alias, ?SavedExport $savedExport = null)
{
if (!$this->filterStatsByCenters) {
return $this->redirectToRoute('chill_main_export_new', [
'step' => $this->getNextStep('centers', $export),
'alias' => $alias,
'from_saved' => $request->get('from_saved', ''),
]);
}
/** @var ExportManager $exportManager */
$exportManager = $this->exportManager;
$form = $this->createCreateFormExport($alias, 'centers', [], $savedExport);
if (Request::METHOD_POST === $request->getMethod()) {
$form->handleRequest($request);
if ($form->isValid()) {
$this->logger->debug('form centers is valid', [
'location' => __METHOD__, ]);
$data = $form->getData();
// check ACL
if (
false === $exportManager->isGrantedForElement(
$export,
null,
$exportManager->getPickedCenters($data['centers'])
)
) {
throw $this->createAccessDeniedException('you do not have access to this export for those centers');
}
$this->session->set(
'centers_step_raw',
$request->request->all()
);
$this->session->set('centers_step', $data);
return $this->redirectToRoute('chill_main_export_new', [
'step' => $this->getNextStep('centers', $export),
'alias' => $alias,
'from_saved' => $request->get('from_saved', ''),
]);
}
}
return $this->render(
'@ChillMain/Export/new_centers_step.html.twig',
[
'form' => $form->createView(),
'export' => $export,
'export_group' => $this->getExportGroup($export),
]
);
}
private function getExportGroup($target): string
{
$exportManager = $this->exportManager;
$groups = $exportManager->getExportsGrouped(true);
foreach ($groups as $group => $array) {
foreach ($array as $alias => $export) {
if ($export === $target) {
return $group;
}
}
}
return '';
}
/**
* get the next step. If $reverse === true, the previous step is returned.
*
* This method provides a centralized way of handling next/previous step.
*
* @param string $step the current step
* @param bool $reverse set to true to get the previous step
*
* @return string the next/current step
*
* @throws \LogicException if there is no step before or after the given step
*/
private function getNextStep($step, DirectExportInterface|ExportInterface $export, $reverse = false)
{
switch ($step) {
case 'centers':
if (false !== $reverse) {
throw new \LogicException("there is no step before 'export'");
}
return 'export';
case 'export':
if ($export instanceof ExportInterface) {
return $reverse ? 'centers' : 'formatter';
}
if ($export instanceof DirectExportInterface) {
return $reverse ? 'centers' : 'generate';
}
// no break
case 'formatter':
return $reverse ? 'export' : 'generate';
case 'generate':
if (false === $reverse) {
throw new \LogicException("there is no step after 'generate'");
}
return 'formatter';
default:
throw new \LogicException("the step {$step} is not defined.");
}
}
private function rebuildRawData(?string $key): array
{
if (null === $key) {
throw $this->createNotFoundException('key does not exists');
}
if (1 !== $this->redis->exists($key)) {
$this->addFlash('error', $this->translator->trans('This report is not available any more'));
throw $this->createNotFoundException('key does not exists');
}
$serialized = $this->redis->get($key);
if (false === $serialized) {
throw new \LogicException('the key could not be reached from redis');
}
$rawData = \unserialize($serialized);
$this->logger->notice('[export] choices for an export unserialized', [
'key' => $key,
'rawData' => json_encode($rawData, JSON_THROW_ON_ERROR),
]);
return $rawData;
}
private function getSavedExportFromRequest(Request $request): ?SavedExport
{
$savedExport = match ($savedExportId = $request->query->get('from_saved', '')) {
'' => null,
default => $this->savedExportRepository->find($savedExportId),
};
if (null !== $savedExport && !$this->security->isGranted(SavedExportVoter::EDIT, $savedExport)) {
throw new AccessDeniedHttpException('saved export edition not allowed');
}
return $savedExport;
}
}