diff --git a/src/Bundle/ChillMainBundle/Controller/ExportController.php b/src/Bundle/ChillMainBundle/Controller/ExportController.php index 96c9ed08d..84bf80c6b 100644 --- a/src/Bundle/ChillMainBundle/Controller/ExportController.php +++ b/src/Bundle/ChillMainBundle/Controller/ExportController.php @@ -11,23 +11,32 @@ declare(strict_types=1); namespace Chill\MainBundle\Controller; +use Chill\MainBundle\Entity\SavedExport; +use Chill\MainBundle\Entity\User; 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\Security\Authorization\SavedExportVoter; +use Doctrine\ORM\EntityManagerInterface; use LogicException; use Psr\Log\LoggerInterface; +use RedisException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 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\Contracts\Translation\TranslatorInterface; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Contracts\Translation\TranslatorInterface; use function count; use function serialize; use function unserialize; @@ -38,35 +47,37 @@ use function unserialize; */ class ExportController extends AbstractController { + private EntityManagerInterface $entityManager; + /** * @var ExportManager */ - protected $exportManager; + private $exportManager; /** * @var FormFactoryInterface */ - protected $formFactory; + private $formFactory; /** * @var LoggerInterface */ - protected $logger; + private $logger; /** * @var ChillRedis */ - protected $redis; + private $redis; /** * @var SessionInterface */ - protected $session; + private $session; /** * @var TranslatorInterface */ - protected $translator; + private $translator; public function __construct( ChillRedis $chillRedis, @@ -74,8 +85,10 @@ class ExportController extends AbstractController FormFactoryInterface $formFactory, LoggerInterface $logger, SessionInterface $session, - TranslatorInterface $translator + TranslatorInterface $translator, + EntityManagerInterface $entityManager ) { + $this->entityManager = $entityManager; $this->redis = $chillRedis; $this->exportManager = $exportManager; $this->formFactory = $formFactory; @@ -142,6 +155,29 @@ class ExportController extends AbstractController ); } + /** + * @Route("/{_locale}/exports/generate-from-saved/{id}", name="chill_main_export_generate_from_saved") + * + * @throws RedisException + */ + public function generateFromSavedExport(SavedExport $savedExport): RedirectResponse + { + $this->denyAccessUnlessGranted(SavedExportVoter::GENERATE, $savedExport); + + $key = md5(uniqid((string) mt_rand(), 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. */ @@ -203,6 +239,46 @@ class ExportController extends AbstractController } } + /** + * @Route("/{_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. * @@ -418,28 +494,7 @@ class ExportController extends AbstractController protected function rebuildData($key) { - if (null === $key) { - throw $this->createNotFoundException('key does not exists'); - } - - if ($this->redis->exists($key) !== 1) { - $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), - ]); + $rawData = $this->rebuildRawData($key); $alias = $rawData['alias']; @@ -585,4 +640,32 @@ class ExportController extends AbstractController 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 ($this->redis->exists($key) !== 1) { + $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), + ]); + + return $rawData; + } } diff --git a/src/Bundle/ChillMainBundle/Controller/SavedExportController.php b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php new file mode 100644 index 000000000..197fe253d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/SavedExportController.php @@ -0,0 +1,185 @@ +exportManager = $exportManager; + $this->entityManager = $entityManager; + $this->formFactory = $formBuilder; + $this->savedExportRepository = $savedExportRepository; + $this->security = $security; + $this->session = $session; + $this->templating = $templating; + $this->translator = $translator; + $this->urlGenerator = $urlGenerator; + } + + /** + * @Route("/{_locale}/exports/saved/{id}/delete", name="chill_main_export_saved_delete") + */ + public function delete(SavedExport $savedExport, Request $request): Response + { + if (!$this->security->isGranted(SavedExportVoter::DELETE, $savedExport)) { + throw new AccessDeniedHttpException(); + } + + $form = $this->formFactory->create(); + $form->add('submit', SubmitType::class, ['label' => 'Delete']); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->remove($savedExport); + $this->entityManager->flush(); + + $this->session->getFlashBag()->add('success', $this->translator->trans('saved_export.Export is deleted')); + + return new RedirectResponse( + $this->urlGenerator->generate('chill_main_export_saved_list_my') + ); + } + + return new Response( + $this->templating->render( + '@ChillMain/SavedExport/delete.html.twig', + [ + 'saved_export' => $savedExport, + 'delete_form' => $form->createView(), + ] + ) + ); + } + + /** + * @Route("/{_locale}/exports/saved/{id}/edit", name="chill_main_export_saved_edit") + */ + public function edit(SavedExport $savedExport, Request $request): Response + { + if (!$this->security->isGranted(SavedExportVoter::EDIT, $savedExport)) { + throw new AccessDeniedHttpException(); + } + + $form = $this->formFactory->create(SavedExportType::class, $savedExport); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->flush(); + + $this->session->getFlashBag()->add('success', $this->translator->trans('saved_export.Saved export is saved!')); + + return new RedirectResponse( + $this->urlGenerator->generate('chill_main_export_saved_list_my') + ); + } + + return new Response( + $this->templating->render( + '@ChillMain/SavedExport/edit.html.twig', + [ + 'form' => $form->createView(), + ] + ) + ); + } + + /** + * @Route("/{_locale}/exports/saved/my", name="chill_main_export_saved_list_my") + */ + public function list(): Response + { + $user = $this->security->getUser(); + + if (!$this->security->isGranted('ROLE_USER') || !$user instanceof User) { + throw new AccessDeniedHttpException(); + } + + $exports = $this->savedExportRepository->findByUser($user, ['title' => 'ASC']); + + // group by center + /** @var array $exportsGrouped */ + $exportsGrouped = []; + + foreach ($exports as $savedExport) { + $export = $this->exportManager->getExport($savedExport->getExportAlias()); + + $exportsGrouped[ + $export instanceof GroupedExportInterface + ? $this->translator->trans($export->getGroup()) : '_' + ][] = ['saved' => $savedExport, 'export' => $export]; + } + + ksort($exportsGrouped); + + return new Response( + $this->templating->render( + '@ChillMain/SavedExport/index.html.twig', + [ + 'grouped_exports' => $exportsGrouped, + 'total' => count($exports), + ] + ) + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/SavedExport.php b/src/Bundle/ChillMainBundle/Entity/SavedExport.php new file mode 100644 index 000000000..3ac361e81 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/SavedExport.php @@ -0,0 +1,133 @@ +id = Uuid::uuid4(); + } + + public function getDescription(): string + { + return $this->description; + } + + public function getExportAlias(): string + { + return $this->exportAlias; + } + + public function getId(): UuidInterface + { + return $this->id; + } + + public function getOptions(): array + { + return $this->options; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getUser(): User + { + return $this->user; + } + + public function setDescription(string $description): SavedExport + { + $this->description = $description; + + return $this; + } + + public function setExportAlias(string $exportAlias): SavedExport + { + $this->exportAlias = $exportAlias; + + return $this; + } + + public function setOptions(array $options): SavedExport + { + $this->options = $options; + + return $this; + } + + public function setTitle(string $title): SavedExport + { + $this->title = $title; + + return $this; + } + + public function setUser(User $user): SavedExport + { + $this->user = $user; + + return $this; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/DataMapper/RollingDateDataMapper.php b/src/Bundle/ChillMainBundle/Form/DataMapper/RollingDateDataMapper.php new file mode 100644 index 000000000..21d0c3fde --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/DataMapper/RollingDateDataMapper.php @@ -0,0 +1,45 @@ +setData($viewData->getRoll()); + $forms['fixedDate']->setData($viewData->getFixedDate()); + } + + public function mapFormsToData($forms, &$viewData): void + { + $forms = iterator_to_array($forms); + + $viewData = new RollingDate( + $forms['roll']->getData(), + $forms['fixedDate']->getData() + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/SavedExportType.php b/src/Bundle/ChillMainBundle/Form/SavedExportType.php new file mode 100644 index 000000000..16aa4e42e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/SavedExportType.php @@ -0,0 +1,40 @@ +add('title', TextType::class, [ + 'required' => true, + ]) + ->add('description', ChillTextareaType::class, [ + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'class' => SavedExport::class, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/Type/PickRollingDateType.php b/src/Bundle/ChillMainBundle/Form/Type/PickRollingDateType.php new file mode 100644 index 000000000..2c90379f2 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/PickRollingDateType.php @@ -0,0 +1,66 @@ +add('roll', ChoiceType::class, [ + 'choices' => array_combine( + array_map(static fn (string $item) => 'rolling_date.' . $item, RollingDate::ALL_T), + RollingDate::ALL_T + ), + 'multiple' => false, + 'expanded' => false, + 'label' => 'rolling_date.roll_movement', + ]) + ->add('fixedDate', ChillDateType::class, [ + 'input' => 'datetime_immutable', + 'label' => 'rolling_date.fixed_date_date', + ]); + + $builder->setDataMapper(new RollingDateDataMapper()); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'class' => RollingDate::class, + 'empty_data' => new RollingDate(RollingDate::T_TODAY), + 'constraints' => [ + new Callback([$this, 'validate']), + ], + ]); + } + + public function validate($data, ExecutionContextInterface $context, $payload): void + { + /** @var RollingDate $data */ + if (RollingDate::T_FIXED_DATE === $data->getRoll() && null === $data->getFixedDate()) { + $context + ->buildViolation('rolling_date.When fixed date is selected, you must provide a date') + ->atPath('roll') + ->addViolation(); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/SavedExportRepository.php b/src/Bundle/ChillMainBundle/Repository/SavedExportRepository.php new file mode 100644 index 000000000..0011c5c43 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/SavedExportRepository.php @@ -0,0 +1,82 @@ + + */ +class SavedExportRepository implements SavedExportRepositoryInterface +{ + private EntityRepository $repository; + + public function __construct(EntityManagerInterface $entityManager) + { + $this->repository = $entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?SavedExport + { + return $this->repository->find($id); + } + + /** + * @return array|SavedExport[] + */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findByUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array + { + $qb = $this->repository->createQueryBuilder('se'); + + $qb + ->where($qb->expr()->eq('se.user', ':user')) + ->setParameter('user', $user); + + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + + foreach ($orderBy as $field => $order) { + $qb->addOrderBy('se.' . $field, $order); + } + + return $qb->getQuery()->getResult(); + } + + public function findOneBy(array $criteria): ?SavedExport + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return SavedExport::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php new file mode 100644 index 000000000..3b168505f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/SavedExportRepositoryInterface.php @@ -0,0 +1,40 @@ + + */ +interface SavedExportRepositoryInterface extends ObjectRepository +{ + public function find($id): ?SavedExport; + + /** + * @return array|SavedExport[] + */ + public function findAll(): array; + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; + + /** + * @return array|SavedExport[] + */ + public function findByUser(User $user, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array; + + public function findOneBy(array $criteria): ?SavedExport; + + public function getClassName(): string; +} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Export/_navbar.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Export/_navbar.html.twig new file mode 100644 index 000000000..a7d7216b4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Export/_navbar.html.twig @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/src/Bundle/ChillMainBundle/Resources/views/Export/download.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Export/download.html.twig index 84eb85100..1e69c5a49 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Export/download.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Export/download.html.twig @@ -49,5 +49,14 @@ window.addEventListener("DOMContentLoaded", function(e) { data-download-text="{{ "Download your report"|trans|escape('html_attr') }}" >{{ "Waiting for your report"|trans ~ '...' }} - + + {% endblock content %} \ No newline at end of file diff --git a/src/Bundle/ChillMainBundle/Resources/views/Export/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Export/layout.html.twig index 9cf53993d..5ce433535 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Export/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Export/layout.html.twig @@ -22,9 +22,11 @@ {% block content %} + + {{ include('@ChillMain/Export/_navbar.html.twig', {'current' : 'common'}) }} +
-

{{ 'Exports list'|trans }}

- +
{% for group, exports in grouped_exports %}{% if group != '_' %} @@ -32,13 +34,17 @@
{% for export_alias, export in exports %}
-

{{ export.title|trans }}

-

{{ export.description|trans }}

-

- - {{ 'Create an export'|trans }} - -

+
+
+

{{ export.title|trans }}

+

{{ export.description|trans }}

+

+ + {{ 'Create an export'|trans }} + +

+
+
{% endfor %}
@@ -52,13 +58,17 @@ {% for export_alias,export in grouped_exports['_'] %}
-

{{ export.title|trans }}

-

{{ export.description|trans }}

-

- - {{ 'Create an export'|trans }} - -

+
+
+

{{ export.title|trans }}

+

{{ export.description|trans }}

+

+ + {{ 'Create an export'|trans }} + +

+
+
{% endfor %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/delete.html.twig b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/delete.html.twig new file mode 100644 index 000000000..5d90699df --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/delete.html.twig @@ -0,0 +1,25 @@ +{% extends '@ChillMain/layout.html.twig' %} + +{% block title 'saved_export.Delete saved ?'|trans %} + +{% block display_content %} +
+

{{ saved_export.title }}

+

{{ saved_export.description|chill_markdown_to_html }}

+ +
+{% endblock %} + +{% block content %} +
+ {{ include('@ChillMain/Util/confirmation_template.html.twig', + { + 'title' : 'saved_export.Delete saved ?'|trans, + 'confirm_question' : 'saved_export.Are you sure you want to delete this saved ?'|trans, + 'display_content' : block('display_content'), + 'cancel_route' : 'chill_main_export_saved_list_my', + 'cancel_parameters' : {}, + 'form' : delete_form + } ) }} +
+{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/edit.html.twig b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/edit.html.twig new file mode 100644 index 000000000..937d42201 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/edit.html.twig @@ -0,0 +1,21 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% block title %}{{ 'saved_export.Edit'|trans }}{% endblock %} + +{% block content %} +

{{ block('title') }}

+ + {{ form_start(form) }} + {{ form_row(form.title) }} + {{ form_row(form.description) }} + + + {{ form_end(form) }} +{% endblock %} \ No newline at end of file diff --git a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig new file mode 100644 index 000000000..17792ecd2 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/index.html.twig @@ -0,0 +1,82 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% block title %}{{ 'saved_export.My saved exports'|trans }}{% endblock %} + +{% block content %} +
+ + {{ include('@ChillMain/Export/_navbar.html.twig', {'current' : 'my'}) }} + +
+ + {% if total == 0 %} +

{{ 'saved_export.Any saved export'|trans }}

+ {% endif %} + + {% for group, saveds in grouped_exports %} + {% if group != '_' %} +

{{ group }}

+
+ {% for s in saveds %} +
+
+
+

{{ s.saved.title }}

+

{{ s.export.title|trans }}

+ +
+ {{ s.saved.description|chill_markdown_to_html }} +
+ +
{{ 'saved_export.Created on %date%'|trans({'%date%': s.saved.createdAt|format_datetime('long', 'short')}) }}
+ +
    +
  • +
  • +
  • +
+ +
+
+
+ {% endfor %} +
+ {% endif %} + {% endfor %} + + {% if grouped_exports|keys|length > 1 and grouped_exports['_']|length > 0 %} +

{{ 'Ungrouped exports'|trans }}

+ {% endif %} + +
+ {% for saveds in grouped_exports['_']|default([]) %} + {% for s in saveds %} +
+
+
+
+

{{ s.saved.title }}

+

{{ s.export.title|trans }}

+ +
+ {{ s.saved.description|chill_markdown_to_html }} +
+ +
{{ 'saved_export.Created on %date%'|trans({'%date%': s.saved.createdAt|format_datetime('long', 'short')}) }}
+ +
    +
  • +
  • +
  • +
+ +
+
+
+
+ {% endfor %} + {% endfor %} +
+
+
+{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/SavedExport/new.html.twig b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/new.html.twig new file mode 100644 index 000000000..037adb0fc --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/SavedExport/new.html.twig @@ -0,0 +1,21 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% block title %}{{ 'saved_export.New'|trans }}{% endblock %} + +{% block content %} +

{{ block('title') }}

+ + {{ form_start(form) }} + {{ form_row(form.title) }} + {{ form_row(form.description) }} + + + {{ form_end(form) }} +{% endblock %} \ No newline at end of file diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/SavedExportVoter.php b/src/Bundle/ChillMainBundle/Security/Authorization/SavedExportVoter.php new file mode 100644 index 000000000..667bced46 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/Authorization/SavedExportVoter.php @@ -0,0 +1,52 @@ +getUser() === $token->getUser(); + + default: + throw new UnexpectedValueException('attribute not supported: ' . $attribute); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDate.php b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDate.php new file mode 100644 index 000000000..e8427c62f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDate.php @@ -0,0 +1,101 @@ +roll = $roll; + $this->pivotDate = $pivotDate ?? new DateTimeImmutable('now'); + $this->fixedDate = $fixedDate; + } + + public function getFixedDate(): ?DateTimeImmutable + { + return $this->fixedDate; + } + + public function getPivotDate(): DateTimeImmutable + { + return $this->pivotDate; + } + + public function getRoll(): string + { + return $this->roll; + } +} diff --git a/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverter.php b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverter.php new file mode 100644 index 000000000..026ff7a8a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverter.php @@ -0,0 +1,142 @@ +getRoll()) { + case RollingDate::T_MONTH_CURRENT_START: + return $this->toBeginOfMonth($rollingDate->getPivotDate()); + + case RollingDate::T_MONTH_NEXT_START: + return $this->toBeginOfMonth($rollingDate->getPivotDate()->add(new DateInterval('P1M'))); + + case RollingDate::T_MONTH_PREVIOUS_START: + return $this->toBeginOfMonth($rollingDate->getPivotDate()->sub(new DateInterval('P1M'))); + + case RollingDate::T_QUARTER_CURRENT_START: + return $this->toBeginOfQuarter($rollingDate->getPivotDate()); + + case RollingDate::T_QUARTER_NEXT_START: + return $this->toBeginOfQuarter($rollingDate->getPivotDate()->add(new DateInterval('P3M'))); + + case RollingDate::T_QUARTER_PREVIOUS_START: + return $this->toBeginOfQuarter($rollingDate->getPivotDate()->sub(new DateInterval('P3M'))); + + case RollingDate::T_WEEK_CURRENT_START: + return $this->toBeginOfWeek($rollingDate->getPivotDate()); + + case RollingDate::T_WEEK_NEXT_START: + return $this->toBeginOfWeek($rollingDate->getPivotDate()->add(new DateInterval('P1W'))); + + case RollingDate::T_WEEK_PREVIOUS_START: + return $this->toBeginOfWeek($rollingDate->getPivotDate()->sub(new DateInterval('P1W'))); + + case RollingDate::T_YEAR_CURRENT_START: + return $this->toBeginOfYear($rollingDate->getPivotDate()); + + case RollingDate::T_YEAR_PREVIOUS_START: + return $this->toBeginOfYear($rollingDate->getPivotDate()->sub(new DateInterval('P1Y'))); + + case RollingDate::T_YEAR_NEXT_START: + return $this->toBeginOfYear($rollingDate->getPivotDate()->add(new DateInterval('P1Y'))); + + case RollingDate::T_TODAY: + return $rollingDate->getPivotDate(); + + case RollingDate::T_FIXED_DATE: + if (null === $rollingDate->getFixedDate()) { + throw new LogicException('You must provide a fixed date when selecting a fixed date'); + } + + return $rollingDate->getFixedDate(); + + default: + throw new UnexpectedValueException(sprintf('%s rolling operation not supported', $rollingDate->getRoll())); + } + } + + private function toBeginOfMonth(DateTimeImmutable $date): DateTimeImmutable + { + return DateTimeImmutable::createFromFormat( + 'Y-m-d His', + sprintf('%s-%s-01 000000', $date->format('Y'), $date->format('m')) + ); + } + + private function toBeginOfQuarter(DateTimeImmutable $date): DateTimeImmutable + { + switch ((int) $date->format('n')) { + case 1: + case 2: + case 3: + $month = '01'; + + break; + + case 4: + case 5: + case 6: + $month = '04'; + + break; + + case 7: + case 8: + case 9: + $month = '07'; + + break; + + case 10: + case 11: + case 12: + $month = '10'; + + break; + + default: + throw new LogicException('this month is not valid: ' . $date->format('n')); + } + + return DateTimeImmutable::createFromFormat( + 'Y-m-d His', + sprintf('%s-%s-01 000000', $date->format('Y'), $month) + ); + } + + private function toBeginOfWeek(DateTimeImmutable $date): DateTimeImmutable + { + if (1 === $dayOfWeek = (int) $date->format('N')) { + return $date->setTime(0, 0, 0); + } + + return $date + ->sub(new DateInterval('P' . ($dayOfWeek - 1) . 'D')) + ->setTime(0, 0, 0); + } + + private function toBeginOfYear(DateTimeImmutable $date): DateTimeImmutable + { + return DateTimeImmutable::createFromFormat( + 'Y-m-d His', + sprintf('%s-01-01 000000', $date->format('Y')) + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverterInterface.php b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverterInterface.php new file mode 100644 index 000000000..b20a5ced2 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverterInterface.php @@ -0,0 +1,19 @@ + 'year_previous_start', + 'fixedDate' => null, + ]; + + $form = $this->factory->create(PickRollingDateType::class); + + $form->submit($formData); + + $this->assertTrue($form->isSynchronized()); + + /** @var RollingDate $rollingDate */ + $rollingDate = $form->getData(); + + $this->assertInstanceOf(RollingDate::class, $rollingDate); + $this->assertEquals(RollingDate::T_YEAR_PREVIOUS_START, $rollingDate->getRoll()); + } + + protected function getExtensions(): array + { + $type = new PickRollingDateType(); + + return [ + new PreloadedExtension([$type], []), + ]; + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Services/RollingDate/RollingDateConverterTest.php b/src/Bundle/ChillMainBundle/Tests/Services/RollingDate/RollingDateConverterTest.php new file mode 100644 index 000000000..6df2c889b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Services/RollingDate/RollingDateConverterTest.php @@ -0,0 +1,101 @@ +converter = new RollingDateConverter(); + } + + public function generateDataConversionDate(): iterable + { + $format = 'Y-m-d His'; + + yield [RollingDate::T_MONTH_CURRENT_START, '2022-11-01 000000', $format]; + + yield [RollingDate::T_MONTH_NEXT_START, '2022-12-01 000000', $format]; + + yield [RollingDate::T_MONTH_PREVIOUS_START, '2022-10-01 000000', $format]; + + yield [RollingDate::T_QUARTER_CURRENT_START, '2022-10-01 000000', $format]; + + yield [RollingDate::T_QUARTER_NEXT_START, '2023-01-01 000000', $format]; + + yield [RollingDate::T_QUARTER_PREVIOUS_START, '2022-07-01 000000', $format]; + + yield [RollingDate::T_TODAY, '2022-11-07 000000', $format]; + + yield [RollingDate::T_WEEK_CURRENT_START, '2022-11-07 000000', $format]; + + yield [RollingDate::T_WEEK_NEXT_START, '2022-11-14 000000', $format]; + + yield [RollingDate::T_WEEK_PREVIOUS_START, '2022-10-31 000000', $format]; + + yield [RollingDate::T_YEAR_CURRENT_START, '2022-01-01 000000', $format]; + + yield [RollingDate::T_YEAR_NEXT_START, '2023-01-01 000000', $format]; + + yield [RollingDate::T_YEAR_PREVIOUS_START, '2021-01-01 000000', $format]; + } + + public function testConversionFixedDate() + { + $rollingDate = new RollingDate(RollingDate::T_FIXED_DATE, new DateTimeImmutable('2022-01-01')); + + $this->assertEquals( + '2022-01-01', + $this->converter->convert($rollingDate)->format('Y-m-d') + ); + } + + public function testConvertOnDateNow() + { + $rollingDate = new RollingDate(RollingDate::T_YEAR_PREVIOUS_START); + + $actual = $this->converter->convert($rollingDate); + + $this->assertEquals( + (int) (new DateTimeImmutable('now'))->format('Y') - 1, + (int) $actual->format('Y') + ); + $this->assertEquals(1, (int) $actual->format('m')); + $this->assertEquals(1, (int) $actual->format('d')); + } + + /** + * @dataProvider generateDataConversionDate + */ + public function testConvertOnPivotDate(string $roll, string $expectedDateTime, string $format) + { + $pivot = DateTimeImmutable::createFromFormat('Y-m-d His', '2022-11-07 000000'); + $rollingDate = new RollingDate($roll, null, $pivot); + + $this->assertEquals( + DateTime::createFromFormat($format, $expectedDateTime), + $this->converter->convert($rollingDate) + ); + } +} diff --git a/src/Bundle/ChillMainBundle/config/services.yaml b/src/Bundle/ChillMainBundle/config/services.yaml index 040db1a9f..1c56c1ad5 100644 --- a/src/Bundle/ChillMainBundle/config/services.yaml +++ b/src/Bundle/ChillMainBundle/config/services.yaml @@ -105,3 +105,8 @@ services: resource: '../Service/Import/' autowire: true autoconfigure: true + + Chill\MainBundle\Service\RollingDate\: + resource: '../Service/RollingDate/' + autowire: true + autoconfigure: true diff --git a/src/Bundle/ChillMainBundle/config/services/security.yaml b/src/Bundle/ChillMainBundle/config/services/security.yaml index 3347871e3..824144470 100644 --- a/src/Bundle/ChillMainBundle/config/services/security.yaml +++ b/src/Bundle/ChillMainBundle/config/services/security.yaml @@ -50,6 +50,8 @@ services: tags: - { name: security.voter } + Chill\MainBundle\Security\Authorization\SavedExportVoter: ~ + Chill\MainBundle\Security\PasswordRecover\TokenManager: arguments: $secret: '%kernel.secret%' diff --git a/src/Bundle/ChillMainBundle/migrations/Version20221107212201.php b/src/Bundle/ChillMainBundle/migrations/Version20221107212201.php new file mode 100644 index 000000000..58285c333 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20221107212201.php @@ -0,0 +1,43 @@ +addSql('DROP TABLE chill_main_saved_export'); + } + + public function getDescription(): string + { + return 'Create table for saved exports'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE chill_main_saved_export (id UUID NOT NULL, user_id INT DEFAULT NULL, description TEXT DEFAULT \'\' NOT NULL, exportAlias TEXT DEFAULT \'\' NOT NULL, options JSONB DEFAULT \'[]\' NOT NULL, title TEXT DEFAULT \'\' NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_C2029B22A76ED395 ON chill_main_saved_export (user_id)'); + $this->addSql('CREATE INDEX IDX_C2029B223174800F ON chill_main_saved_export (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_C2029B2265FF1AEC ON chill_main_saved_export (updatedBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_main_saved_export.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN chill_main_saved_export.options IS \'(DC2Type:json)\''); + $this->addSql('COMMENT ON COLUMN chill_main_saved_export.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_main_saved_export.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_main_saved_export ADD CONSTRAINT FK_C2029B22A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_saved_export ADD CONSTRAINT FK_C2029B223174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_main_saved_export ADD CONSTRAINT FK_C2029B2265FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 3e229b8d4..58b3e976e 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -538,3 +538,32 @@ export: isNoAddress: Adresse incomplète ? _lat: Latitude _lon: Longitude + +rolling_date: + year_previous_start: Début de l'année précédente + quarter_previous_start: Début du trimestre précédent + month_previous_start: Début du mois précédent + week_previous_start: Début de la semaine précédente + year_current_start: Début de l'année courante + quarter_current_start: Début du trimestre courant + month_current_start: Début du mois courant + week_current_start: Début de la semaine courante + today: Aujourd'hui (aucune modification de la date courante) + year_next_start: Début de l'année suivante + quarter_next_start: Début du trimestre suivante + month_next_start: Début du mois suivant + week_next_start: Début de la semaine suivante + fixed_date: Date fixe + roll_movement: Modification par rapport à aujourd'hui + fixed_date_date: Date fixe + +saved_export: + Any saved export: Aucun rapport enregistré + New: Nouveau rapport enregistré + Edit: Modifier un rapport enregistré + Delete saved ?: Supprimer un rapport enregistré ? + Are you sure you want to delete this saved ?: Êtes-vous sûr·e de vouloir supprimer ce rapport ? + My saved exports: Mes rapports enregistrés + Export is deleted: Le rapport est supprimé + Saved export is saved!: Le rapport est enregistré + Created on %date%: Créé le %date% \ No newline at end of file diff --git a/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/StepAggregator.php b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/StepAggregator.php index fc2fcc7b2..e17e9f9a5 100644 --- a/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/StepAggregator.php +++ b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/StepAggregator.php @@ -12,10 +12,11 @@ declare(strict_types=1); namespace Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators; use Chill\MainBundle\Export\AggregatorInterface; -use Chill\MainBundle\Form\Type\ChillDateType; +use Chill\MainBundle\Form\Type\PickRollingDateType; +use Chill\MainBundle\Service\RollingDate\RollingDate; +use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Export\Declarations; -use DateTime; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -27,11 +28,15 @@ final class StepAggregator implements AggregatorInterface private const P = 'acp_step_agg_date'; + private RollingDateConverterInterface $rollingDateConverter; + private TranslatorInterface $translator; public function __construct( + RollingDateConverterInterface $rollingDateConverter, TranslatorInterface $translator ) { + $this->rollingDateConverter = $rollingDateConverter; $this->translator = $translator; } @@ -60,7 +65,7 @@ final class StepAggregator implements AggregatorInterface ) ) ) - ->setParameter(self::P, $data['on_date']) + ->setParameter(self::P, $this->rollingDateConverter->convert($data['on_date'])) ->addGroupBy('step_aggregator'); } @@ -71,8 +76,8 @@ final class StepAggregator implements AggregatorInterface public function buildForm(FormBuilderInterface $builder) { - $builder->add('on_date', ChillDateType::class, [ - 'data' => new DateTime(), + $builder->add('on_date', PickRollingDateType::class, [ + 'data' => new RollingDate(RollingDate::T_TODAY), ]); } diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php index 5b8335c55..07ca1de75 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php @@ -12,15 +12,23 @@ declare(strict_types=1); namespace Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters; use Chill\MainBundle\Export\FilterInterface; -use Chill\MainBundle\Form\Type\ChillDateType; +use Chill\MainBundle\Form\Type\PickRollingDateType; +use Chill\MainBundle\Service\RollingDate\RollingDate; +use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; use Chill\PersonBundle\Export\Declarations; -use DateTime; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\FormBuilderInterface; class OpenBetweenDatesFilter implements FilterInterface { + private RollingDateConverterInterface $rollingDateConverter; + + public function __construct(RollingDateConverterInterface $rollingDateConverter) + { + $this->rollingDateConverter = $rollingDateConverter; + } + public function addRole(): ?string { return null; @@ -34,8 +42,8 @@ class OpenBetweenDatesFilter implements FilterInterface ); $qb->andWhere($clause); - $qb->setParameter('datefrom', $data['date_from'], Types::DATE_MUTABLE); - $qb->setParameter('dateto', $data['date_to'], Types::DATE_MUTABLE); + $qb->setParameter('datefrom', $this->rollingDateConverter->convert($data['date_from']), Types::DATE_IMMUTABLE); + $qb->setParameter('dateto', $this->rollingDateConverter->convert($data['date_to']), Types::DATE_IMMUTABLE); } public function applyOn(): string @@ -46,19 +54,19 @@ class OpenBetweenDatesFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { $builder - ->add('date_from', ChillDateType::class, [ - 'data' => new DateTime(), + ->add('date_from', PickRollingDateType::class, [ + 'data' => new RollingDate(RollingDate::T_MONTH_PREVIOUS_START), ]) - ->add('date_to', ChillDateType::class, [ - 'data' => new DateTime(), + ->add('date_to', PickRollingDateType::class, [ + 'data' => new RollingDate(RollingDate::T_TODAY), ]); } public function describeAction($data, $format = 'string'): array { return ['Filtered by opening dates: between %datefrom% and %dateto%', [ - '%datefrom%' => $data['date_from']->format('d-m-Y'), - '%dateto%' => $data['date_to']->format('d-m-Y'), + '%datefrom%' => $this->rollingDateConverter->convert($data['date_from'])->format('d-m-Y'), + '%dateto%' => $this->rollingDateConverter->convert($data['date_to'])->format('d-m-Y'), ]]; } diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/StepFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/StepFilter.php index 2eb1b90b3..0c9b28e8c 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/StepFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/StepFilter.php @@ -12,31 +12,40 @@ declare(strict_types=1); namespace Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters; use Chill\MainBundle\Export\FilterInterface; +use Chill\MainBundle\Form\Type\PickRollingDateType; +use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Export\Declarations; -use Doctrine\ORM\Query\Expr\Andx; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Contracts\Translation\TranslatorInterface; +use function in_array; class StepFilter implements FilterInterface { + private const A = 'acp_filter_bystep_stephistories'; + private const DEFAULT_CHOICE = AccompanyingPeriod::STEP_CONFIRMED; + private const P = 'acp_step_filter_date'; + private const STEPS = [ 'Draft' => AccompanyingPeriod::STEP_DRAFT, 'Confirmed' => AccompanyingPeriod::STEP_CONFIRMED, 'Closed' => AccompanyingPeriod::STEP_CLOSED, ]; + private RollingDateConverterInterface $rollingDateConverter; + /** * @var TranslatorInterface */ - protected $translator; + private $translator; - public function __construct(TranslatorInterface $translator) + public function __construct(RollingDateConverterInterface $rollingDateConverter, TranslatorInterface $translator) { + $this->rollingDateConverter = $rollingDateConverter; $this->translator = $translator; } @@ -47,17 +56,25 @@ class StepFilter implements FilterInterface public function alterQuery(QueryBuilder $qb, $data) { - $where = $qb->getDQLPart('where'); - $clause = $qb->expr()->eq('acp.step', ':step'); - - if ($where instanceof Andx) { - $where->add($clause); - } else { - $where = $qb->expr()->andX($clause); + if (!in_array(self::A, $qb->getAllAliases(), true)) { + $qb->leftJoin('acp.stepHistories', self::A); } - $qb->add('where', $where); - $qb->setParameter('step', $data['accepted_steps']); + $qb + ->andWhere( + $qb->expr()->andX( + $qb->expr()->lte(self::A . '.startDate', ':' . self::P), + $qb->expr()->orX( + $qb->expr()->isNull(self::A . '.endDate'), + $qb->expr()->lt(self::A . '.endDate', ':' . self::P) + ) + ) + ) + ->andWhere( + $qb->expr()->in(self::A . '.step', ':acp_filter_by_step_steps') + ) + ->setParameter(self::P, $this->rollingDateConverter->convert($data['calc_date'])) + ->setParameter('acp_filter_by_step_steps', $data['accepted_steps']); } public function applyOn() @@ -67,13 +84,17 @@ class StepFilter implements FilterInterface public function buildForm(FormBuilderInterface $builder) { - $builder->add('accepted_steps', ChoiceType::class, [ - 'choices' => self::STEPS, - 'multiple' => false, - 'expanded' => true, - 'empty_data' => self::DEFAULT_CHOICE, - 'data' => self::DEFAULT_CHOICE, - ]); + $builder + ->add('accepted_steps', ChoiceType::class, [ + 'choices' => self::STEPS, + 'multiple' => false, + 'expanded' => true, + 'empty_data' => self::DEFAULT_CHOICE, + 'data' => self::DEFAULT_CHOICE, + ]) + ->add('calc_date', PickRollingDateType::class, [ + 'label' => 'export.acp.filter.by_step.date_calc', + ]); } public function describeAction($data, $format = 'string')