Merge branch 'rector/rules-up-to-php82' into rector/rules-symfony

This commit is contained in:
2023-07-19 23:22:57 +02:00
577 changed files with 18339 additions and 2168 deletions

View File

@@ -30,6 +30,7 @@ use Chill\MainBundle\Search\SearchApiInterface;
use Chill\MainBundle\Security\ProvideRoleInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverInterface;
use Chill\MainBundle\Security\Resolver\ScopeResolverInterface;
use Chill\MainBundle\Service\EntityInfo\ViewEntityInfoProviderInterface;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderInterface;
use Chill\MainBundle\Templating\UI\NotificationCounterInterface;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
@@ -62,6 +63,8 @@ class ChillMainBundle extends Bundle
->addTag('chill_main.workflow_handler');
$container->registerForAutoconfiguration(CronJobInterface::class)
->addTag('chill_main.cron_job');
$container->registerForAutoconfiguration(ViewEntityInfoProviderInterface::class)
->addTag('chill_main.entity_info_provider');
$container->addCompilerPass(new SearchableServicesCompilerPass());
$container->addCompilerPass(new ConfigConsistencyCompilerPass());

View File

@@ -0,0 +1,39 @@
<?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\Command;
use Chill\MainBundle\Service\EntityInfo\ViewEntityInfoManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SynchronizeEntityInfoViewsCommand extends Command
{
public function __construct(
private readonly ViewEntityInfoManager $viewEntityInfoManager,
) {
parent::__construct('chill:db:sync-views');
}
protected function configure(): void
{
$this
->setDescription('Update or create sql views which provide info for various entities');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->viewEntityInfoManager->synchronizeOnDB();
return 0;
}
}

View File

@@ -13,12 +13,16 @@ 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 LogicException;
@@ -36,6 +40,7 @@ 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;
use function count;
use function serialize;
@@ -47,20 +52,29 @@ use function unserialize;
*/
class ExportController extends AbstractController
{
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)
{
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
) {
}
public function downloadResultAction(Request $request, $alias)
{
/** @var \Chill\MainBundle\Export\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);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key, $savedExport);
$formatterAlias = $exportManager->getFormatterAlias($dataExport['export']);
@@ -74,6 +88,7 @@ class ExportController extends AbstractController
'alias' => $alias,
'export' => $export,
'export_group' => $this->getExportGroup($export),
'saved_export' => $savedExport,
];
if ($formater instanceof \Chill\MainBundle\Export\Formatter\CSVListFormatter) {
@@ -98,8 +113,9 @@ class ExportController extends AbstractController
/** @var \Chill\MainBundle\Export\ExportManager $exportManager */
$exportManager = $this->exportManager;
$key = $request->query->get('key', null);
$savedExport = $this->getSavedExportFromRequest($request);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key);
[$dataCenters, $dataExport, $dataFormatter] = $this->rebuildData($key, $savedExport);
return $exportManager->generate(
$alias,
@@ -158,12 +174,8 @@ class ExportController extends AbstractController
* 3. 'generate': gather data from session from the previous steps, and
* make a redirection to the "generate" action with data in query (HTTP GET)
*
* @param string $request
* @param Request $alias
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function newAction(Request $request, $alias)
public function newAction(Request $request, string $alias): Response
{
// first check for ACL
$exportManager = $this->exportManager;
@@ -173,17 +185,41 @@ class ExportController extends AbstractController
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),
'export' => $this->exportFormStep($request, $export, $alias),
'formatter' => $this->formatterFormStep($request, $export, $alias),
'generate' => $this->forwardToGenerate($request, $export, $alias),
'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("/{_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("/{_locale}/export/save-from-key/{alias}/{key}", name="chill_main_export_save_from_key")
*/
@@ -227,44 +263,55 @@ class ExportController extends AbstractController
/**
* create a form to show on different steps.
*
* @param string $alias
* @param array $data the data from previous step. Required for steps 'formatter' and 'generate_formatter'
*/
protected function createCreateFormExport($alias, mixed $step, $data = []): FormInterface
protected function createCreateFormExport(string $alias, string $step, array $data, ?SavedExport $savedExport): FormInterface
{
/** @var \Chill\MainBundle\Export\ExportManager $exportManager */
$exportManager = $this->exportManager;
$isGenerate = str_starts_with((string) $step, 'generate_');
$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(null, FormType::class, [], [
'method' => $isGenerate ? 'GET' : 'POST',
'csrf_protection' => $isGenerate ? false : true,
]);
// TODO: add a condition to be able to select a regroupment of centers?
->createNamedBuilder(
null,
FormType::class,
$defaultFormData,
[
'method' => $isGenerate ? 'GET' : 'POST',
'csrf_protection' => !$isGenerate,
]
);
if ('centers' === $step || 'generate_centers' === $step) {
$builder->add('centers', PickCenterType::class, [
'export_alias' => $alias,
]);
$builder->add('centers', PickCenterType::class, $options);
}
if ('export' === $step || 'generate_export' === $step) {
$builder->add('export', ExportType::class, [
'export_alias' => $alias,
'picked_centers' => $exportManager->getPickedCenters($data['centers']),
]);
$builder->add('export', ExportType::class, $options);
}
if ('formatter' === $step || 'generate_formatter' === $step) {
$builder->add('formatter', FormatterType::class, [
'formatter_alias' => $exportManager
->getFormatterAlias($data['export']),
'export_alias' => $alias,
'aggregator_aliases' => $exportManager
->getUsedAggregatorsAliases($data['export']),
]);
$builder->add('formatter', FormatterType::class, $options);
}
$builder->add('submit', SubmitType::class, [
@@ -279,13 +326,8 @@ class ExportController extends AbstractController
*
* When the method is POST, the form is stored if valid, and a redirection
* is done to next step.
*
* @param string $alias
* @param \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export
*
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function exportFormStep(Request $request, \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export, $alias)
private function exportFormStep(Request $request, DirectExportInterface|ExportInterface $export, string $alias, ?SavedExport $savedExport = null): Response
{
$exportManager = $this->exportManager;
@@ -301,7 +343,7 @@ class ExportController extends AbstractController
$export = $exportManager->getExport($alias);
$form = $this->createCreateFormExport($alias, 'export', $data);
$form = $this->createCreateFormExport($alias, 'export', $data, $savedExport);
if ($request->getMethod() === 'POST') {
$form->handleRequest($request);
@@ -323,6 +365,7 @@ class ExportController extends AbstractController
$this->generateUrl('chill_main_export_new', [
'step' => $this->getNextStep('export', $export),
'alias' => $alias,
'from_saved' => $request->get('from_saved', '')
])
);
}
@@ -343,13 +386,8 @@ class ExportController extends AbstractController
*
* If the form is posted and valid, store the data in session and
* redirect to the next step.
*
* @param \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export
* @param string $alias
*
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function formatterFormStep(Request $request, \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export, $alias)
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);
@@ -361,7 +399,7 @@ class ExportController extends AbstractController
]);
}
$form = $this->createCreateFormExport($alias, 'formatter', $data);
$form = $this->createCreateFormExport($alias, 'formatter', $data, $savedExport);
if ($request->getMethod() === 'POST') {
$form->handleRequest($request);
@@ -380,6 +418,7 @@ class ExportController extends AbstractController
[
'alias' => $alias,
'step' => $this->getNextStep('formatter', $export),
'from_saved' => $request->get('from_saved', ''),
]
));
}
@@ -406,7 +445,7 @@ class ExportController extends AbstractController
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
protected function forwardToGenerate(Request $request, \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export, $alias)
private function forwardToGenerate(Request $request, \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export, $alias, ?SavedExport $savedExport)
{
$dataCenters = $this->session->get('centers_step_raw', null);
$dataFormatter = $this->session->get('formatter_step_raw', null);
@@ -414,7 +453,9 @@ class ExportController extends AbstractController
if (null === $dataFormatter && $export instanceof \Chill\MainBundle\Export\ExportInterface) {
return $this->redirectToRoute('chill_main_export_new', [
'alias' => $alias, 'step' => $this->getNextStep('generate', $export, true),
'alias' => $alias,
'step' => $this->getNextStep('generate', $export, true),
'from_saved' => $savedExport?->getId() ?? '',
]);
}
@@ -435,20 +476,24 @@ class ExportController extends AbstractController
$this->session->remove('formatter_step_raw');
$this->session->remove('formatter_step');
return $this->redirectToRoute('chill_main_export_download', ['key' => $key, 'alias' => $alias]);
return $this->redirectToRoute('chill_main_export_download', [
'key' => $key,
'alias' => $alias,
'from_saved' => $savedExport?->getId(),
]);
}
protected function rebuildData($key)
private function rebuildData($key, ?SavedExport $savedExport)
{
$rawData = $this->rebuildRawData($key);
$alias = $rawData['alias'];
$formCenters = $this->createCreateFormExport($alias, 'generate_centers');
$formCenters = $this->createCreateFormExport($alias, 'generate_centers', [], $savedExport);
$formCenters->submit($rawData['centers']);
$dataCenters = $formCenters->getData();
$formExport = $this->createCreateFormExport($alias, 'generate_export', $dataCenters);
$formExport = $this->createCreateFormExport($alias, 'generate_export', $dataCenters, $savedExport);
$formExport->submit($rawData['export']);
$dataExport = $formExport->getData();
@@ -456,7 +501,8 @@ class ExportController extends AbstractController
$formFormatter = $this->createCreateFormExport(
$alias,
'generate_formatter',
$dataExport
$dataExport,
$savedExport
);
$formFormatter->submit($rawData['formatter']);
$dataFormatter = $formFormatter->getData();
@@ -469,12 +515,12 @@ class ExportController extends AbstractController
* @param string $alias
* @return Response
*/
protected function selectCentersStep(Request $request, \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export, $alias)
private function selectCentersStep(Request $request, \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export, $alias, ?SavedExport $savedExport = null)
{
/** @var \Chill\MainBundle\Export\ExportManager $exportManager */
$exportManager = $this->exportManager;
$form = $this->createCreateFormExport($alias, 'centers');
$form = $this->createCreateFormExport($alias, 'centers', [], $savedExport);
if ($request->getMethod() === 'POST') {
$form->handleRequest($request);
@@ -506,6 +552,7 @@ class ExportController extends AbstractController
return $this->redirectToRoute('chill_main_export_new', [
'step' => $this->getNextStep('centers', $export),
'alias' => $alias,
'from_saved' => $request->get('from_saved', ''),
]);
}
}
@@ -612,4 +659,18 @@ class ExportController extends AbstractController
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;
}
}

View File

@@ -16,14 +16,18 @@ use Chill\MainBundle\Entity\RoleScope;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Form\PermissionsGroupType;
use Chill\MainBundle\Form\Type\ComposedRoleScopeType;
use Chill\MainBundle\Repository\PermissionsGroupRepository;
use Chill\MainBundle\Repository\RoleScopeRepository;
use Chill\MainBundle\Security\RoleProvider;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Security\Core\Role\RoleHierarchy;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -32,27 +36,28 @@ use function array_key_exists;
/**
* Class PermissionsGroupController.
*/
class PermissionsGroupController extends AbstractController
final class PermissionsGroupController extends AbstractController
{
/**
* PermissionsGroupController constructor.
*/
public function __construct(private readonly TranslatableStringHelper $translatableStringHelper, private readonly RoleProvider $roleProvider, private readonly RoleHierarchy $roleHierarchy, private readonly TranslatorInterface $translator, private readonly ValidatorInterface $validator)
{
public function __construct(
private readonly TranslatableStringHelper $translatableStringHelper,
private readonly RoleProvider $roleProvider,
private readonly RoleHierarchyInterface $roleHierarchy,
private readonly TranslatorInterface $translator,
private readonly ValidatorInterface $validator,
private readonly EntityManagerInterface $em,
private readonly PermissionsGroupRepository $permissionsGroupRepository,
private readonly RoleScopeRepository $roleScopeRepository,
) {
}
/**
* @param int $id
*
* @throws type
*
* @return Respon
*/
public function addLinkRoleScopeAction(Request $request, $id)
public function addLinkRoleScopeAction(Request $request, int $id): Response
{
$em = $this->getDoctrine()->getManager();
$permissionsGroup = $em->getRepository(\Chill\MainBundle\Entity\PermissionsGroup::class)->find($id);
$permissionsGroup = $this->permissionsGroupRepository->find($id);
if (!$permissionsGroup) {
throw $this->createNotFoundException('Unable to find PermissionsGroup entity.');
@@ -71,7 +76,7 @@ class PermissionsGroupController extends AbstractController
$violations = $this->validator->validate($permissionsGroup);
if ($violations->count() === 0) {
$em->flush();
$this->em->flush();
$this->addFlash(
'notice',
@@ -131,16 +136,15 @@ class PermissionsGroupController extends AbstractController
/**
* Creates a new PermissionsGroup entity.
*/
public function createAction(Request $request)
public function createAction(Request $request): Response
{
$permissionsGroup = new PermissionsGroup();
$form = $this->createCreateForm($permissionsGroup);
$form->handleRequest($request);
if ($form->isValid()) {
$em = $this->getDoctrine()->getManager();
$em->persist($permissionsGroup);
$em->flush();
$this->em->persist($permissionsGroup);
$this->em->flush();
return $this->redirect($this->generateUrl(
'admin_permissionsgroup_edit',
@@ -156,18 +160,11 @@ class PermissionsGroupController extends AbstractController
/**
* remove an association between permissionsGroup and roleScope.
*
* @param int $pgid permissionsGroup id
* @param int $rsid roleScope id
*
* @return redirection to edit form
*/
public function deleteLinkRoleScopeAction($pgid, $rsid)
public function deleteLinkRoleScopeAction(int $pgid, int $rsid): Response
{
$em = $this->getDoctrine()->getManager();
$permissionsGroup = $em->getRepository(\Chill\MainBundle\Entity\PermissionsGroup::class)->find($pgid);
$roleScope = $em->getRepository(\Chill\MainBundle\Entity\RoleScope::class)->find($rsid);
$permissionsGroup = $this->permissionsGroupRepository->find($pgid);
$roleScope = $this->roleScopeRepository->find($rsid);
if (!$permissionsGroup) {
throw $this->createNotFoundException('Unable to find PermissionsGroup entity.');
@@ -196,7 +193,7 @@ class PermissionsGroupController extends AbstractController
));
}
$em->flush();
$this->em->flush();
if ($roleScope->getScope() !== null) {
$this->addFlash(
@@ -226,11 +223,9 @@ class PermissionsGroupController extends AbstractController
/**
* Displays a form to edit an existing PermissionsGroup entity.
*/
public function editAction(mixed $id)
public function editAction(int $id): Response
{
$em = $this->getDoctrine()->getManager();
$permissionsGroup = $em->getRepository(\Chill\MainBundle\Entity\PermissionsGroup::class)->find($id);
$permissionsGroup = $this->permissionsGroupRepository->find($id);
if (!$permissionsGroup) {
throw $this->createNotFoundException('Unable to find PermissionsGroup entity.');
@@ -274,11 +269,9 @@ class PermissionsGroupController extends AbstractController
/**
* Lists all PermissionsGroup entities.
*/
public function indexAction()
public function indexAction(): Response
{
$em = $this->getDoctrine()->getManager();
$entities = $em->getRepository(\Chill\MainBundle\Entity\PermissionsGroup::class)->findAll();
$entities = $this->permissionsGroupRepository->findAllOrderedAlphabetically();
return $this->render('@ChillMain/PermissionsGroup/index.html.twig', [
'entities' => $entities,
@@ -288,7 +281,7 @@ class PermissionsGroupController extends AbstractController
/**
* Displays a form to create a new PermissionsGroup entity.
*/
public function newAction()
public function newAction(): Response
{
$permissionsGroup = new PermissionsGroup();
$form = $this->createCreateForm($permissionsGroup);
@@ -302,11 +295,9 @@ class PermissionsGroupController extends AbstractController
/**
* Finds and displays a PermissionsGroup entity.
*/
public function showAction(mixed $id)
public function showAction(int $id): Response
{
$em = $this->getDoctrine()->getManager();
$permissionsGroup = $em->getRepository(\Chill\MainBundle\Entity\PermissionsGroup::class)->find($id);
$permissionsGroup = $this->permissionsGroupRepository->find($id);
if (!$permissionsGroup) {
throw $this->createNotFoundException('Unable to find PermissionsGroup entity.');
@@ -355,12 +346,9 @@ class PermissionsGroupController extends AbstractController
/**
* Edits an existing PermissionsGroup entity.
*/
public function updateAction(Request $request, mixed $id)
public function updateAction(Request $request, int $id): Response
{
$em = $this->getDoctrine()->getManager();
$permissionsGroup = $em
->getRepository(\Chill\MainBundle\Entity\PermissionsGroup::class)
$permissionsGroup = $this->permissionsGroupRepository
->find($id);
if (!$permissionsGroup) {
@@ -372,7 +360,7 @@ class PermissionsGroupController extends AbstractController
$editForm->handleRequest($request);
if ($editForm->isValid()) {
$em->flush();
$this->em->flush();
return $this->redirect($this->generateUrl('admin_permissionsgroup_edit', ['id' => $id]));
}
@@ -411,18 +399,11 @@ class PermissionsGroupController extends AbstractController
/**
* get a role scope by his parameters. The role scope is persisted if it
* doesn't exists in database.
*
* @param Scope $scope
* @param string $role
*
* @return RoleScope
* doesn't exist in database.
*/
protected function getPersistentRoleScopeBy($role, ?Scope $scope = null)
protected function getPersistentRoleScopeBy(string $role, ?Scope $scope = null): RoleScope
{
$em = $this->getDoctrine()->getManager();
$roleScope = $em->getRepository(\Chill\MainBundle\Entity\RoleScope::class)
$roleScope = $this->roleScopeRepository
->findOneBy(['role' => $role, 'scope' => $scope]);
if (null === $roleScope) {
@@ -430,7 +411,7 @@ class PermissionsGroupController extends AbstractController
->setRole($role)
->setScope($scope);
$em->persist($roleScope);
$this->em->persist($roleScope);
}
return $roleScope;
@@ -438,10 +419,8 @@ class PermissionsGroupController extends AbstractController
/**
* creates a form to add a role scope to permissionsgroup.
*
* @return \Symfony\Component\Form\Form The form
*/
private function createAddRoleScopeForm(PermissionsGroup $permissionsGroup)
private function createAddRoleScopeForm(PermissionsGroup $permissionsGroup): FormInterface
{
return $this->createFormBuilder()
->setAction($this->generateUrl(
@@ -458,10 +437,8 @@ class PermissionsGroupController extends AbstractController
* Creates a form to create a PermissionsGroup entity.
*
* @param PermissionsGroup $permissionsGroup The entity
*
* @return \Symfony\Component\Form\Form The form
*/
private function createCreateForm(PermissionsGroup $permissionsGroup)
private function createCreateForm(PermissionsGroup $permissionsGroup): FormInterface
{
$form = $this->createForm(PermissionsGroupType::class, $permissionsGroup, [
'action' => $this->generateUrl('admin_permissionsgroup_create'),
@@ -477,13 +454,11 @@ class PermissionsGroupController extends AbstractController
* Creates a form to delete a link to roleScope.
*
* @param mixed $permissionsGroup The entity id
*
* @return \Symfony\Component\Form\Form The form
*/
private function createDeleteRoleScopeForm(
PermissionsGroup $permissionsGroup,
RoleScope $roleScope
) {
): FormInterface {
return $this->createFormBuilder()
->setAction($this->generateUrl(
'admin_permissionsgroup_delete_role_scope',
@@ -496,12 +471,8 @@ class PermissionsGroupController extends AbstractController
/**
* Creates a form to edit a PermissionsGroup entity.
*
* @param PermissionsGroup $permissionsGroup The entity
*
* @return \Symfony\Component\Form\Form The form
*/
private function createEditForm(PermissionsGroup $permissionsGroup)
private function createEditForm(PermissionsGroup $permissionsGroup): FormInterface
{
$form = $this->createForm(PermissionsGroupType::class, $permissionsGroup, [
'action' => $this->generateUrl('admin_permissionsgroup_update', ['id' => $permissionsGroup->getId()]),
@@ -515,10 +486,8 @@ class PermissionsGroupController extends AbstractController
/**
* expand roleScopes to be easily shown in template.
*
* @return array
*/
private function getExpandedRoles(array $roleScopes)
private function getExpandedRoles(array $roleScopes): array
{
$expandedRoles = [];
@@ -526,10 +495,10 @@ class PermissionsGroupController extends AbstractController
if (!array_key_exists($roleScope->getRole(), $expandedRoles)) {
$expandedRoles[$roleScope->getRole()] =
array_map(
static fn (Role $role) => $role->getRole(),
static fn ($role) => $role,
$this->roleHierarchy
->getReachableRoles(
[new Role($roleScope->getRole())]
->getReachableRoleNames(
[$roleScope->getRole()]
)
);
}

View File

@@ -0,0 +1,142 @@
<?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\Repository\UserRepositoryInterface;
use League\Csv\Writer;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class UserExportController
{
public function __construct(
private UserRepositoryInterface $userRepository,
private Security $security,
private TranslatorInterface $translator,
) {
}
/**
* @throws \League\Csv\CannotInsertRecord
* @throws \League\Csv\Exception
* @throws \League\Csv\UnavailableStream
*
* @Route("/{_locale}/admin/main/users/export/list.{_format}", requirements={"_format": "csv"}, name="chill_main_users_export_list")
*/
public function userList(Request $request, string $_format = 'csv'): StreamedResponse
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Only ROLE_ADMIN can export this list');
}
$users = $this->userRepository->findAllAsArray($request->getLocale());
$csv = Writer::createFromPath('php://temp', 'r+');
$csv->insertOne(
array_map(
fn (string $e) => $this->translator->trans('admin.users.export.' . $e),
[
'id',
'username',
'email',
'enabled',
'civility_id',
'civility_abbreviation',
'civility_name',
'label',
'mainCenter_id' ,
'mainCenter_name',
'mainScope_id',
'mainScope_name',
'userJob_id',
'userJob_name',
'currentLocation_id',
'currentLocation_name',
'mainLocation_id',
'mainLocation_name',
'absenceStart'
]
)
);
$csv->addFormatter(fn (array $row) => null !== ($row['absenceStart'] ?? null) ? array_merge($row, ['absenceStart' => $row['absenceStart']->format('Y-m-d')]) : $row);
$csv->insertAll($users);
return new StreamedResponse(
function () use ($csv) {
foreach ($csv->chunk(1024) as $chunk) {
echo $chunk;
flush();
}
},
Response::HTTP_OK,
[
'Content-Encoding' => 'none',
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; users.csv',
]
);
}
/**
* @throws \League\Csv\CannotInsertRecord
* @throws \League\Csv\Exception
* @throws \League\Csv\UnavailableStream
* @Route("/{_locale}/admin/main/users/export/permissions.{_format}", requirements={"_format": "csv"}, name="chill_main_users_export_permissions")
*/
public function userPermissionsList(string $_format = 'csv'): StreamedResponse
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Only ROLE_ADMIN can export this list');
}
$userPermissions = $this->userRepository->findAllUserACLAsArray();
$csv = Writer::createFromPath('php://temp', 'r+');
$csv->insertOne(
array_map(
fn (string $e) => $this->translator->trans('admin.users.export.' . $e),
[
'id',
'username',
'email',
'label',
'enabled',
'center_id',
'center_name',
'permissionsGroup_id',
'permissionsGroup_name',
]
)
);
$csv->insertAll($userPermissions);
return new StreamedResponse(
function () use ($csv) {
foreach ($csv->chunk(1024) as $chunk) {
echo $chunk;
flush();
}
},
Response::HTTP_OK,
[
'Content-Encoding' => 'none',
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; users.csv',
]
);
}
}

View File

@@ -335,9 +335,9 @@ class WorkflowController extends AbstractController
}
// TODO symfony 5: add those "future" on context ($workflow->apply($entityWorkflow, $transition, $context)
$entityWorkflow->futureCcUsers = $transitionForm['future_cc_users']->getData();
$entityWorkflow->futureDestUsers = $transitionForm['future_dest_users']->getData();
$entityWorkflow->futureDestEmails = $transitionForm['future_dest_emails']->getData();
$entityWorkflow->futureCcUsers = $transitionForm['future_cc_users']->getData() ?? [];
$entityWorkflow->futureDestUsers = $transitionForm['future_dest_users']->getData() ?? [];
$entityWorkflow->futureDestEmails = $transitionForm['future_dest_emails']->getData() ?? [];
$workflow->apply($entityWorkflow, $transition);

View File

@@ -19,5 +19,13 @@ interface CronJobInterface
public function getKey(): string;
public function run(): void;
/**
* Execute the cronjob
*
* If data is returned, this data is passed as argument on the next execution
*
* @param array $lastExecutionData the data which was returned from the previous execution
* @return array|null optionally return an array with the same data than the previous execution
*/
public function run(array $lastExecutionData): null|array;
}

View File

@@ -14,6 +14,7 @@ namespace Chill\MainBundle\Cron;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Repository\CronJobExecutionRepositoryInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Psr\Log\LoggerInterface;
@@ -38,7 +39,7 @@ use function array_key_exists;
* If a tasks is "forced", there is no test about eligibility of the task (the `canRun` method is not called),
* and the last task execution is not recorded.
*/
class CronManager implements CronManagerInterface
final readonly class CronManager implements CronManagerInterface
{
private const LOG_PREFIX = '[cron manager] ';
@@ -46,11 +47,17 @@ class CronManager implements CronManagerInterface
private const UPDATE_BEFORE_EXEC = 'UPDATE ' . CronJobExecution::class . ' cr SET cr.lastStart = :now WHERE cr.key = :key';
private const UPDATE_LAST_EXECUTION_DATA = 'UPDATE ' . CronJobExecution::class . ' cr SET cr.lastExecutionData = :data WHERE cr.key = :key';
/**
* @param CronJobInterface[] $jobs
*/
public function __construct(private readonly CronJobExecutionRepositoryInterface $cronJobExecutionRepository, private readonly EntityManagerInterface $entityManager, private readonly iterable $jobs, private readonly LoggerInterface $logger)
{
public function __construct(
private CronJobExecutionRepositoryInterface $cronJobExecutionRepository,
private EntityManagerInterface $entityManager,
private iterable $jobs,
private LoggerInterface $logger
) {
}
public function run(?string $forceJob = null): void
@@ -66,6 +73,9 @@ class CronManager implements CronManagerInterface
foreach ($orderedJobs as $job) {
if ($job->canRun($lasts[$job->getKey()] ?? null)) {
if (array_key_exists($job->getKey(), $lasts)) {
$executionData = $lasts[$job->getKey()]->getLastExecutionData();
$this->entityManager
->createQuery(self::UPDATE_BEFORE_EXEC)
->setParameters([
@@ -77,12 +87,17 @@ class CronManager implements CronManagerInterface
$execution = new CronJobExecution($job->getKey());
$this->entityManager->persist($execution);
$this->entityManager->flush();
$executionData = $execution->getLastExecutionData();
}
$this->entityManager->clear();
// note: at this step, the entity manager does not have any entity CronJobExecution
// into his internal memory
try {
$this->logger->info(sprintf('%sWill run job', self::LOG_PREFIX), ['job' => $job->getKey()]);
$job->run();
$result = $job->run($executionData);
$this->entityManager
->createQuery(self::UPDATE_AFTER_EXEC)
@@ -93,6 +108,14 @@ class CronManager implements CronManagerInterface
])
->execute();
if (null !== $result) {
$this->entityManager
->createQuery(self::UPDATE_LAST_EXECUTION_DATA)
->setParameter('data', $result, Types::JSON)
->setParameter('key', $job->getKey(), Types::STRING)
->execute();
}
$this->logger->info(sprintf('%sSuccessfully run job', self::LOG_PREFIX), ['job' => $job->getKey()]);
return;
@@ -114,7 +137,7 @@ class CronManager implements CronManagerInterface
}
/**
* @return array<0: CronJobInterface[], 1: array<string, CronJobExecution>>
* @return array{0: array<CronJobInterface>, 1: array<string, CronJobExecution>}
*/
private function getOrderedJobs(): array
{
@@ -155,7 +178,7 @@ class CronManager implements CronManagerInterface
{
foreach ($this->jobs as $job) {
if ($job->getKey() === $forceJob) {
$job->run();
$job->run([]);
}
}
}

View File

@@ -81,16 +81,14 @@ class OverlapsI extends FunctionNode
if ($part instanceof PathExpression) {
return sprintf(
"CASE WHEN %s IS NOT NULL THEN %s ELSE '%s'::date END",
$part->dispatch($sqlWalker),
"COALESCE(%s, '%s'::date)",
$part->dispatch($sqlWalker),
$p
);
}
return sprintf(
"CASE WHEN %s::date IS NOT NULL THEN %s::date ELSE '%s'::date END",
$part->dispatch($sqlWalker),
"COALESCE(%s::date, '%s'::date)",
$part->dispatch($sqlWalker),
$p
);

View File

@@ -38,12 +38,12 @@ class TrackCreateUpdateSubscriber implements EventSubscriber
{
$object = $args->getObject();
if (
$object instanceof TrackCreationInterface
&& $this->security->getUser() instanceof User
) {
$object->setCreatedBy($this->security->getUser());
if ($object instanceof TrackCreationInterface) {
$object->setCreatedAt(new DateTimeImmutable('now'));
if ($this->security->getUser() instanceof User) {
$object->setCreatedBy($this->security->getUser());
}
}
$this->onUpdate($object);
@@ -58,12 +58,12 @@ class TrackCreateUpdateSubscriber implements EventSubscriber
protected function onUpdate(object $object): void
{
if (
$object instanceof TrackUpdateInterface
&& $this->security->getUser() instanceof User
) {
$object->setUpdatedBy($this->security->getUser());
if ($object instanceof TrackUpdateInterface) {
$object->setUpdatedAt(new DateTimeImmutable('now'));
if ($this->security->getUser() instanceof User) {
$object->setUpdatedBy($this->security->getUser());
}
}
}
}

View File

@@ -110,7 +110,7 @@ class Address implements TrackCreationInterface, TrackUpdateInterface
* This list is computed by a materialized view. It won't be populated until a refresh is done
* on the materialized view.
*
* @var Collection<int, GeographicalUnit>|GeographicalUnit[]
* @var Collection<GeographicalUnit>
* @readonly
* @ORM\ManyToMany(targetEntity=GeographicalUnit::class)
* @ORM\JoinTable(

View File

@@ -23,14 +23,14 @@ use Symfony\Component\Serializer\Annotation as Serializer;
class Center implements HasCenterInterface, \Stringable
{
/**
* @var Collection
* @var Collection<GroupCenter>
*
* @ORM\OneToMany(
* targetEntity="Chill\MainBundle\Entity\GroupCenter",
* mappedBy="center"
* )
*/
private $groupCenters;
private Collection $groupCenters;
/**
* @var int
@@ -48,12 +48,19 @@ class Center implements HasCenterInterface, \Stringable
*/
private string $name = '';
/**
* @var Collection<Regroupment>
* @ORM\ManyToMany(targetEntity=Regroupment::class, mappedBy="centers")
*/
private Collection $regroupments;
/**
* Center constructor.
*/
public function __construct()
{
$this->groupCenters = new \Doctrine\Common\Collections\ArrayCollection();
$this->groupCenters = new ArrayCollection();
$this->regroupments = new ArrayCollection();
}
/**
@@ -106,6 +113,14 @@ class Center implements HasCenterInterface, \Stringable
return $this->name;
}
/**
* @return Collection<Regroupment>
*/
public function getRegroupments(): Collection
{
return $this->regroupments;
}
/**
* @param $name
*

View File

@@ -25,7 +25,6 @@ class CronJobExecution
final public const SUCCESS = 1;
/**
* @var DateTimeImmutable
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": null})
*/
private ?DateTimeImmutable $lastEnd = null;
@@ -40,6 +39,11 @@ class CronJobExecution
*/
private ?int $lastStatus = null;
/**
* @ORM\Column(type="json", options={"default": "'{}'::jsonb", "jsonb": true})
*/
private array $lastExecutionData = [];
public function __construct(/**
* @ORM\Column(type="text", nullable=false)
* @ORM\Id
@@ -89,4 +93,16 @@ class CronJobExecution
return $this;
}
public function getLastExecutionData(): array
{
return $this->lastExecutionData;
}
public function setLastExecutionData(array $lastExecutionData): CronJobExecution
{
$this->lastExecutionData = $lastExecutionData;
return $this;
}
}

View File

@@ -45,6 +45,7 @@ class GeographicalUnitLayer
private string $refId = '';
/**
* @var Collection<GeographicalUnit>
* @ORM\OneToMany(targetEntity=GeographicalUnit::class, mappedBy="layer")
*/
private Collection $units;

View File

@@ -42,6 +42,7 @@ class Notification implements TrackUpdateInterface
private array $addedAddresses = [];
/**
* @var Collection<User>
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_notification_addresses_user")
*/
@@ -65,6 +66,7 @@ class Notification implements TrackUpdateInterface
private ?ArrayCollection $addressesOnLoad = null;
/**
* @var Collection<NotificationComment>
* @ORM\OneToMany(targetEntity=NotificationComment::class, mappedBy="notification", orphanRemoval=true)
* @ORM\OrderBy({"createdAt": "ASC"})
*/
@@ -112,6 +114,7 @@ class Notification implements TrackUpdateInterface
private string $title = '';
/**
* @var Collection<User>
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_notification_addresses_unread")
*/

View File

@@ -32,14 +32,13 @@ class PermissionsGroup
private $flags = [];
/**
* @var Collection
*
* @var Collection<GroupCenter>
* @ORM\OneToMany(
* targetEntity="Chill\MainBundle\Entity\GroupCenter",
* mappedBy="permissionsGroup"
* )
*/
private $groupCenters;
private Collection $groupCenters;
/**
* @var int
@@ -58,15 +57,14 @@ class PermissionsGroup
private $name;
/**
* @var Collection
*
* @var Collection<RoleScope>
* @ORM\ManyToMany(
* targetEntity="Chill\MainBundle\Entity\RoleScope",
* inversedBy="permissionsGroups",
* cascade={ "persist" })
* @ORM\Cache(usage="NONSTRICT_READ_WRITE")
*/
private $roleScopes;
private Collection $roleScopes;
/**
* PermissionsGroup constructor.

View File

@@ -22,11 +22,12 @@ use Doctrine\ORM\Mapping as ORM;
class Regroupment
{
/**
* @var Center
* @ORM\ManyToMany(
* targetEntity=Center::class
* targetEntity=Center::class,
* inversedBy="regroupments"
* )
* @ORM\Id
* @var Collection<Center>
*/
private Collection $centers;
@@ -52,6 +53,26 @@ class Regroupment
$this->centers = new ArrayCollection();
}
public function addCenter(Center $center): self
{
if (!$this->centers->contains($center)) {
$this->centers->add($center);
$center->getRegroupments()->add($this);
}
return $this;
}
public function removeCenter(Center $center): self
{
if ($this->centers->contains($center)) {
$this->centers->removeElement($center);
$center->getRegroupments()->removeElement($this);
}
return $this;
}
public function getCenters(): Collection
{
return $this->centers;

View File

@@ -30,13 +30,13 @@ class RoleScope
private ?int $id = null;
/**
* @var Collection
* @var Collection<PermissionsGroup>
*
* @ORM\ManyToMany(
* targetEntity="Chill\MainBundle\Entity\PermissionsGroup",
* mappedBy="roleScopes")
*/
private $permissionsGroups;
private Collection $permissionsGroups;
/**
* @ORM\Column(type="string", length=255)

View File

@@ -51,14 +51,14 @@ class Scope
private array $name = [];
/**
* @var Collection
* @var Collection<RoleScope>
*
* @ORM\OneToMany(
* targetEntity="Chill\MainBundle\Entity\RoleScope",
* mappedBy="scope")
* @ORM\Cache(usage="NONSTRICT_READ_WRITE")
*/
private $roleScopes;
private Collection $roleScopes;
/**
* Scope constructor.

View File

@@ -83,6 +83,7 @@ class User implements UserInterface, \Stringable
private bool $enabled = true;
/**
* @var Collection<GroupCenter>
* @ORM\ManyToMany(
* targetEntity="Chill\MainBundle\Entity\GroupCenter",
* inversedBy="users")
@@ -271,10 +272,7 @@ class User implements UserInterface, \Stringable
return ['ROLE_USER'];
}
/**
* @return string|null
*/
public function getSalt()
public function getSalt(): ?string
{
return $this->salt;
}
@@ -502,11 +500,11 @@ class User implements UserInterface, \Stringable
*
* @return User
*/
public function setUsername($name)
public function setUsername(?string $name)
{
$this->username = $name;
$this->username = (string) $name;
if (empty($this->getLabel())) {
if ("" === trim($this->getLabel())) {
$this->setLabel($name);
}

View File

@@ -37,7 +37,7 @@ class UserJob
protected ?int $id = null;
/**
* @var array|string[]A
* @var array<string, string>
* @ORM\Column(name="label", type="json")
* @Serializer\Groups({"read", "docgen:read"})
* @Serializer\Context({"is-translatable": true}, groups={"docgen:read"})

View File

@@ -75,7 +75,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
/**
* @ORM\OneToMany(targetEntity=EntityWorkflowComment::class, mappedBy="entityWorkflow", orphanRemoval=true)
*
* @var Collection|EntityWorkflowComment[]
* @var Collection<EntityWorkflowComment>
*/
private Collection $comments;
@@ -100,7 +100,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
* @ORM\OneToMany(targetEntity=EntityWorkflowStep::class, mappedBy="entityWorkflow", orphanRemoval=true, cascade={"persist"})
* @ORM\OrderBy({"transitionAt": "ASC", "id": "ASC"})
* @Assert\Valid(traverse=true)
* @var Collection|EntityWorkflowStep[]
* @var Collection<EntityWorkflowStep>
*/
private Collection $steps;
@@ -113,7 +113,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_subscriber_to_final")
*
* @var Collection|User[]
* @var Collection<User>
*/
private Collection $subscriberToFinal;
@@ -121,7 +121,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_subscriber_to_step")
*
* @var Collection|User[]
* @var Collection<User>
*/
private Collection $subscriberToStep;

View File

@@ -33,6 +33,7 @@ class EntityWorkflowStep
private string $accessKey;
/**
* @var Collection<User>
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_step_cc_user")
*/
@@ -54,12 +55,14 @@ class EntityWorkflowStep
private array $destEmail = [];
/**
* @var Collection<User>
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_step_user")
*/
private Collection $destUser;
/**
* @var Collection<User>
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_step_user_by_accesskey")
*/

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Closure;
use Symfony\Component\Form\FormBuilderInterface;
/**
* Interface for Aggregators.
@@ -21,6 +22,16 @@ use Closure;
*/
interface AggregatorInterface extends ModifierInterface
{
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
/**
* Get the default data, that can be use as "data" for the form
*/
public function getFormDefaultData(): array;
/**
* get a callable which will be able to transform the results into
* viewable and understable string.
@@ -67,7 +78,7 @@ interface AggregatorInterface extends ModifierInterface
*
* @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(); }`
*/
public function getLabels($key, array $values, $data);
public function getLabels($key, array $values, mixed $data);
/**
* give the list of keys the current export added to the queryBuilder in

View File

@@ -11,10 +11,21 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Response;
interface DirectExportInterface extends ExportElementInterface
{
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
/**
* Get the default data, that can be use as "data" for the form
*/
public function getFormDefaultData(): array;
/**
* Generate the export.
*/

View File

@@ -19,11 +19,6 @@ use Symfony\Component\Form\FormBuilderInterface;
*/
interface ExportElementInterface
{
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
/**
* get a title, which will be used in UI (and translated).
*

View File

@@ -33,5 +33,5 @@ interface ExportElementValidatedInterface
*
* @param mixed $data the data, as returned by the user
*/
public function validateForm($data, ExecutionContextInterface $context);
public function validateForm(mixed $data, ExecutionContextInterface $context);
}

View File

@@ -0,0 +1,171 @@
<?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\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;
final readonly class ExportFormHelper
{
public function __construct(
private AuthorizationHelperForCurrentUserInterface $authorizationHelper,
private ExportManager $exportManager,
private FormFactoryInterface $formFactory,
) {
}
public function getDefaultData(string $step, ExportInterface|DirectExportInterface $export, array $options = []): array
{
return match ($step) {
'centers', 'generate_centers' => ['centers' => $this->authorizationHelper->getReachableCenters($export->requiredRole())],
'export', 'generate_export' => ['export' => $this->getDefaultDataStepExport($export, $options)],
'formatter', 'generate_formatter' => ['formatter' => $this->getDefaultDataStepFormatter($options)],
default => throw new \LogicException("step not allowed : " . $step),
};
}
private function getDefaultDataStepFormatter(array $options): array
{
$formatter = $this->exportManager->getFormatter($options['formatter_alias']);
return $formatter->getFormDefaultData($options['aggregator_aliases']);
}
private function getDefaultDataStepExport(ExportInterface|DirectExportInterface $export, array $options): array
{
$data = [
ExportType::EXPORT_KEY => $export->getFormDefaultData(),
ExportType::FILTER_KEY => [],
ExportType::AGGREGATOR_KEY => [],
ExportType::PICK_FORMATTER_KEY => [],
];
$filters = $this->exportManager->getFiltersApplyingOn($export, $options['picked_centers']);
foreach ($filters as $alias => $filter) {
$data[ExportType::FILTER_KEY][$alias] = [
FilterType::ENABLED_FIELD => false,
'form' => $filter->getFormDefaultData()
];
}
$aggregators = $this->exportManager
->getAggregatorsApplyingOn($export, $options['picked_centers']);
foreach ($aggregators as $alias => $aggregator) {
$data[ExportType::AGGREGATOR_KEY][$alias] = [
'enabled' => false,
'form' => $aggregator->getFormDefaultData(),
];
}
if ($export instanceof ExportInterface) {
$allowedFormatters = $this->exportManager
->getFormattersByTypes($export->getAllowedFormattersTypes());
$choices = [];
foreach (array_keys(iterator_to_array($allowedFormatters)) as $alias) {
$choices[] = $alias;
}
$data[ExportType::PICK_FORMATTER_KEY]['alias'] = match (count($choices)) {
1 => $choices[0],
default => null,
};
} else {
unset($data[ExportType::PICK_FORMATTER_KEY]);
}
return $data;
}
public function savedExportDataToFormData(
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),
default => throw new \LogicException("this step is not allowed: " . $step),
};
}
private function savedExportDataToFormDataStepCenter(
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();
}
private function savedExportDataToFormDataStepExport(
SavedExport $savedExport,
array $formOptions
): array {
$builder = $this->formFactory
->createBuilder(
FormType::class,
[],
[
'csrf_protection' => false,
]
);
$builder->add('export', ExportType::class, [
'export_alias' => $savedExport->getExportAlias(), ...$formOptions
]);
$form = $builder->getForm();
$form->submit($savedExport->getOptions()['export']);
return $form->getData();
}
private function savedExportDataToFormDataStepFormatter(
SavedExport $savedExport,
array $formOptions
): array {
$builder = $this->formFactory
->createBuilder(
FormType::class,
[],
[
'csrf_protection' => false,
]
);
$builder->add('formatter', FormatterType::class, [
'export_alias' => $savedExport->getExportAlias(), ...$formOptions
]);
$form = $builder->getForm();
$form->submit($savedExport->getOptions()['formatter']);
return $form->getData();
}
}

View File

@@ -13,6 +13,7 @@ namespace Chill\MainBundle\Export;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
/**
* Interface for Export.
@@ -28,6 +29,16 @@ use Doctrine\ORM\QueryBuilder;
*/
interface ExportInterface extends ExportElementInterface
{
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
/**
* Get the default data, that can be use as "data" for the form
*/
public function getFormDefaultData(): array;
/**
* Return which formatter type is allowed for this report.
*
@@ -86,9 +97,9 @@ interface ExportInterface extends ExportElementInterface
* @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 mixed $data The data from the export's form (as defined in `buildForm`)
*
* @return callable(null|string|int|float|'_header' $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(null|string|int|float|'_header' $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(); }`
*/
public function getLabels($key, array $values, $data);
public function getLabels($key, array $values, mixed $data);
/**
* give the list of keys the current export added to the queryBuilder in

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Chill\MainBundle\Form\Type\Export\ExportType;
use Chill\MainBundle\Form\Type\Export\PickCenterType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Doctrine\ORM\QueryBuilder;
use Generator;
@@ -105,8 +104,12 @@ class ExportManager
*
* @return FilterInterface[] a \Generator that contains filters. The key is the filter's alias
*/
public function &getFiltersApplyingOn(ExportInterface $export, ?array $centers = null)
public function &getFiltersApplyingOn(ExportInterface|DirectExportInterface $export, ?array $centers = null): iterable
{
if ($export instanceof DirectExportInterface) {
return;
}
foreach ($this->filters as $alias => $filter) {
if (
in_array($filter->applyOn(), $export->supportsModifiers(), true)
@@ -122,11 +125,11 @@ class ExportManager
*
* @internal This class check the interface implemented by export, and, if ´ListInterface´ is used, return an empty array
*
* @return AggregatorInterface[] a \Generator that contains aggretagors. The key is the filter's alias
* @return null|iterable<string, AggregatorInterface> a \Generator that contains aggretagors. The key is the filter's alias
*/
public function &getAggregatorsApplyingOn(ExportInterface $export, ?array $centers = null)
public function &getAggregatorsApplyingOn(ExportInterface|DirectExportInterface $export, ?array $centers = null): ?iterable
{
if ($export instanceof ListInterface) {
if ($export instanceof ListInterface || $export instanceof DirectExportInterface) {
return;
}
@@ -140,7 +143,7 @@ class ExportManager
}
}
public function addExportElementsProvider(ExportElementsProviderInterface $provider, $prefix)
public function addExportElementsProvider(ExportElementsProviderInterface $provider, string $prefix): void
{
foreach ($provider->getExportElements() as $suffix => $element) {
$alias = $prefix . '_' . $suffix;
@@ -164,23 +167,16 @@ class ExportManager
* add a formatter.
*
* @internal used by DI
*
* @param string $alias
*/
public function addFormatter(FormatterInterface $formatter, $alias)
public function addFormatter(FormatterInterface $formatter, string $alias)
{
$this->formatters[$alias] = $formatter;
}
/**
* Generate a response which contains the requested data.
*
* @param string $exportAlias
* @param mixed[] $data
*
* @return Response
*/
public function generate($exportAlias, array $pickedCentersData, array $data, array $formatterData)
public function generate(string $exportAlias, array $pickedCentersData, array $data, array $formatterData): Response
{
$export = $this->getExport($exportAlias);
$centers = $this->getPickedCenters($pickedCentersData);
@@ -279,7 +275,10 @@ class ExportManager
return $this->aggregators[$alias];
}
public function getAggregators(array $aliases)
/**
* @return iterable<string, AggregatorInterface>
*/
public function getAggregators(array $aliases): iterable
{
foreach ($aliases as $alias) {
yield $alias => $this->getAggregator($alias);
@@ -287,9 +286,11 @@ class ExportManager
}
/**
* @return string[] the existing type for known exports
* Get the types for known exports
*
* @return list<string> the existing type for known exports
*/
public function getExistingExportsTypes()
public function getExistingExportsTypes(): array
{
$existingTypes = [];
@@ -308,10 +309,8 @@ class ExportManager
* @param string $alias
*
* @throws RuntimeException
*
* @return ExportInterface
*/
public function getExport($alias)
public function getExport($alias): ExportInterface|DirectExportInterface
{
if (!array_key_exists($alias, $this->exports)) {
throw new RuntimeException("The export with alias {$alias} is not known.");
@@ -325,9 +324,9 @@ class ExportManager
*
* @param bool $whereUserIsGranted if true (default), restrict to user which are granted the right to execute the export
*
* @return ExportInterface[] an array where export's alias are keys
* @return iterable<string, ExportInterface|DirectExportInterface> an array where export's alias are keys
*/
public function getExports($whereUserIsGranted = true)
public function getExports($whereUserIsGranted = true): iterable
{
foreach ($this->exports as $alias => $export) {
if ($whereUserIsGranted) {
@@ -343,11 +342,10 @@ class ExportManager
/**
* Get all exports grouped in an array.
*
* @param bool $whereUserIsGranted
*
* @return array where keys are the groups's name and value is an array of exports
* @return array<string, array<string, ExportInterface|DirectExportInterface>> where keys are the groups's name and value is an array of exports
*/
public function getExportsGrouped($whereUserIsGranted = true): array
public function getExportsGrouped(bool $whereUserIsGranted = true): array
{
$groups = ['_' => []];
@@ -363,13 +361,9 @@ class ExportManager
}
/**
* @param string $alias
*
* @throws RuntimeException if the filter is not known
*
* @return FilterInterface
*/
public function getFilter($alias)
public function getFilter(string $alias): FilterInterface
{
if (!array_key_exists($alias, $this->filters)) {
throw new RuntimeException("The filter with alias {$alias} is not known.");
@@ -381,16 +375,17 @@ class ExportManager
/**
* get all filters.
*
* @param Generator $aliases
* @param array<string> $aliases
* @return iterable<string, FilterInterface> $aliases
*/
public function getFilters(array $aliases)
public function getFilters(array $aliases): iterable
{
foreach ($aliases as $alias) {
yield $alias => $this->getFilter($alias);
}
}
public function getFormatter($alias)
public function getFormatter(string $alias): FormatterInterface
{
if (!array_key_exists($alias, $this->formatters)) {
throw new RuntimeException("The formatter with alias {$alias} is not known.");
@@ -405,7 +400,7 @@ class ExportManager
* @param array $data the data from the export form
* @string the formatter alias|null
*/
public function getFormatterAlias(array $data)
public function getFormatterAlias(array $data): ?string
{
if (array_key_exists(ExportType::PICK_FORMATTER_KEY, $data)) {
return $data[ExportType::PICK_FORMATTER_KEY]['alias'];
@@ -417,9 +412,9 @@ class ExportManager
/**
* Get all formatters which supports one of the given types.
*
* @return Generator
* @return iterable<string, FormatterInterface>
*/
public function getFormattersByTypes(array $types)
public function getFormattersByTypes(array $types): iterable
{
foreach ($this->formatters as $alias => $formatter) {
if (in_array($formatter->getType(), $types, true)) {
@@ -436,7 +431,7 @@ class ExportManager
*
* @return \Chill\MainBundle\Entity\Center[] the picked center
*/
public function getPickedCenters(array $data)
public function getPickedCenters(array $data): array
{
return $data;
}
@@ -446,9 +441,9 @@ class ExportManager
*
* @param array $data the data from the export form
*
* @return string[]
* @return list<string>
*/
public function getUsedAggregatorsAliases(array $data)
public function getUsedAggregatorsAliases(array $data): array
{
$aggregators = $this->retrieveUsedAggregators($data[ExportType::AGGREGATOR_KEY]);
@@ -460,9 +455,8 @@ class ExportManager
* center, false if the user hasn't access to element for at least one center.
*
*
* @return bool
*/
public function isGrantedForElement(ExportElementInterface $element, \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export = null, ?array $centers = null)
public function isGrantedForElement(ExportElementInterface $element, \Chill\MainBundle\Export\DirectExportInterface|\Chill\MainBundle\Export\ExportInterface $export = null, ?array $centers = null): bool
{
if ($element instanceof ExportInterface || $element instanceof DirectExportInterface) {
$role = $element->requiredRole();
@@ -537,13 +531,12 @@ class ExportManager
* Check for acl. If an user is not authorized to see an aggregator, throw an
* UnauthorizedException.
*
* @param type $data
* @throw UnauthorizedHttpException if the user is not authorized
*/
private function handleAggregators(
ExportInterface $export,
QueryBuilder $qb,
$data,
array $data,
array $center
) {
$aggregators = $this->retrieveUsedAggregators($data);
@@ -587,9 +580,9 @@ class ExportManager
}
/**
* @return AggregatorInterface[]
* @return iterable<string, AggregatorInterface>
*/
private function retrieveUsedAggregators(mixed $data)
private function retrieveUsedAggregators(mixed $data): iterable
{
if (null === $data) {
return [];

View File

@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\MainBundle\Export;
use Symfony\Component\Form\FormBuilderInterface;
/**
* Interface for filters.
*
@@ -23,6 +25,16 @@ interface FilterInterface extends ModifierInterface
{
public const STRING_FORMAT = 'string';
/**
* Add a form to collect data from the user.
*/
public function buildForm(FormBuilderInterface $builder);
/**
* Get the default data, that can be use as "data" for the form
*/
public function getFormDefaultData(): array;
/**
* Describe the filtering action.
*

View File

@@ -83,6 +83,11 @@ class CSVFormatter implements FormatterInterface
}
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return [];
}
public function gatherFiltersDescriptions()
{
$descriptions = [];

View File

@@ -85,10 +85,14 @@ class CSVListFormatter implements FormatterInterface
'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 vertical list';

View File

@@ -84,6 +84,11 @@ class CSVPivotedListFormatter implements FormatterInterface
]);
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return ['numerotation' => true];
}
public function getName()
{
return 'CSV horizontal list';

View File

@@ -173,7 +173,19 @@ class SpreadSheetFormatter implements FormatterInterface
}
}
public function getName()
public function getFormDefaultData(array $aggregatorAliases): array
{
$data = ['format' => 'xlsx'];
$aggregators = iterator_to_array($this->exportManager->getAggregators($aggregatorAliases));
foreach (array_keys($aggregators) as $index => $alias) {
$data[$alias] = ['order' => $index + 1];
}
return $data;
}
public function getName(): string
{
return 'SpreadSheet (xlsx, ods)';
}
@@ -185,7 +197,7 @@ class SpreadSheetFormatter implements FormatterInterface
array $exportData,
array $filtersData,
array $aggregatorsData
) {
): Response {
// store all data when the process is initiated
$this->result = $result;
$this->formatterData = $formatterData;
@@ -558,10 +570,8 @@ class SpreadSheetFormatter implements FormatterInterface
*
* This form allow to choose the aggregator position (row or column) and
* the ordering
*
* @param string $nbAggregators
*/
private function appendAggregatorForm(FormBuilderInterface $builder, $nbAggregators)
private function appendAggregatorForm(FormBuilderInterface $builder, int $nbAggregators): void
{
$builder->add('order', ChoiceType::class, [
'choices' => array_combine(

View File

@@ -104,10 +104,14 @@ class SpreadsheetListFormatter implements FormatterInterface
'expanded' => true,
'multiple' => false,
'label' => 'Add a number on first column',
'data' => true,
]);
}
public function getFormDefaultData(array $aggregatorAliases): array
{
return ['numerotation' => true, 'format' => 'xlsx'];
}
public function getName()
{
return 'Spreadsheet list formatter (.xlsx, .ods)';

View File

@@ -34,6 +34,11 @@ interface FormatterInterface
array $aggregatorAliases
);
/**
* get the default data for the form build by buildForm
*/
public function getFormDefaultData(array $aggregatorAliases): array;
public function getName();
/**

View File

@@ -20,35 +20,23 @@ use Symfony\Component\Form\FormInterface;
use function array_key_exists;
use function count;
class ExportPickCenterDataMapper implements DataMapperInterface
final readonly class ExportPickCenterDataMapper implements DataMapperInterface
{
protected RegroupmentRepository $regroupmentRepository;
public function mapDataToForms($data, $forms): void
public function mapDataToForms($viewData, $forms): void
{
if (null === $data) {
if (null === $viewData) {
return;
}
/** @var array<string, FormInterface> $form */
$form = iterator_to_array($forms);
$pickedRegroupment = [];
$form['center']->setData($viewData);
foreach ($this->regroupmentRepository->findAll() as $regroupment) {
/** @phpstan-ignore-next-line */
[$contained, $notContained] = $regroupment->getCenters()->partition(static fn (Center $center): bool => false);
if (0 === count($notContained)) {
$pickedRegroupment[] = $regroupment;
}
}
$form['regroupment']->setData($pickedRegroupment);
$form['centers']->setData($data);
// NOTE: we do not map back the regroupments
}
public function mapFormsToData($forms, &$data): void
public function mapFormsToData($forms, &$viewData): void
{
/** @var array<string, FormInterface> $forms */
$forms = iterator_to_array($forms);
@@ -68,6 +56,6 @@ class ExportPickCenterDataMapper implements DataMapperInterface
}
}
$data = array_values($centers);
$viewData = array_values($centers);
}
}

View File

@@ -31,7 +31,7 @@ class RegroupmentType extends AbstractType
->add('centers', EntityType::class, [
'class' => Center::class,
'multiple' => true,
'attr' => ['class' => 'select2'],
'expanded' => true,
])
->add('isActive', CheckboxType::class, [
'label' => 'Actif ?',

View File

@@ -32,7 +32,6 @@ class AggregatorType extends AbstractType
->add('enabled', CheckboxType::class, [
'value' => true,
'required' => false,
'data' => false,
]);
$filterFormBuilder = $builder->create('form', FormType::class, [

View File

@@ -33,7 +33,6 @@ class FilterType extends AbstractType
$builder
->add(self::ENABLED_FIELD, CheckboxType::class, [
'value' => true,
'data' => false,
'required' => false,
]);

View File

@@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Form\DataMapper\ExportPickCenterDataMapper;
use Chill\MainBundle\Repository\RegroupmentRepository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
@@ -33,33 +34,30 @@ final class PickCenterType extends AbstractType
{
public const CENTERS_IDENTIFIERS = 'c';
private readonly UserInterface $user;
public function __construct(
TokenStorageInterface $tokenStorage,
private readonly ExportManager $exportManager,
private readonly RegroupmentRepository $regroupmentRepository,
private readonly AuthorizationHelperInterface $authorizationHelper
private readonly AuthorizationHelperForCurrentUserInterface $authorizationHelper
) {
$this->user = $tokenStorage->getToken()->getUser();
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$export = $this->exportManager->getExport($options['export_alias']);
$centers = $this->authorizationHelper->getReachableCenters(
$this->user,
$export->requiredRole()
);
// order alphabetically
usort($centers, fn (Center $a, Center $b) => $a->getCenter() <=> $b->getName());
$builder->add('center', EntityType::class, [
'class' => Center::class,
'label' => 'center',
'choices' => $centers,
'label' => 'center',
'multiple' => true,
'expanded' => true,
'choice_label' => static fn (Center $c) => $c->getName(),
'data' => $centers,
]);
if (count($this->regroupmentRepository->findAllActive()) > 0) {

View File

@@ -47,8 +47,6 @@ class PickFormatterType extends AbstractType
'multiple' => false,
'placeholder' => 'Choose a format',
]);
//$builder->get('type')->addModelTransformer($transformer);
}
public function configureOptions(OptionsResolver $resolver)

View File

@@ -12,7 +12,10 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\Listing;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
@@ -27,10 +30,6 @@ use function count;
final class FilterOrderType extends \Symfony\Component\Form\AbstractType
{
public function __construct(private readonly RequestStack $requestStack)
{
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var FilterOrderHelper $helper */
@@ -40,22 +39,16 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType
$builder->add('q', SearchType::class, [
'label' => false,
'required' => false,
'attr' => [
'placeholder' => 'filter_order.Search',
]
]);
}
$checkboxesBuilder = $builder->create('checkboxes', null, ['compound' => true]);
foreach ($helper->getCheckboxes() as $name => $c) {
$choices = array_combine(
array_map(static function ($c, $t) {
if (null !== $t) {
return $t;
}
return $c;
}, $c['choices'], $c['trans']),
$c['choices']
);
$choices = self::buildCheckboxChoices($c['choices'], $c['trans']);
$checkboxesBuilder->add($name, ChoiceType::class, [
'choices' => $choices,
@@ -68,6 +61,25 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType
$builder->add($checkboxesBuilder);
}
if ([] !== $helper->getEntityChoices()) {
$entityChoicesBuilder = $builder->create('entity_choices', null, ['compound' => true]);
foreach ($helper->getEntityChoices() as $key => [
'label' => $label, 'choices' => $choices, 'options' => $opts, 'class' => $class
]) {
$entityChoicesBuilder->add($key, EntityType::class, [
'label' => $label,
'choices' => $choices,
'class' => $class,
'multiple' => true,
'expanded' => true,
...$opts,
]);
}
$builder->add($entityChoicesBuilder);
}
if (0 < count($helper->getDateRanges())) {
$dateRangesBuilder = $builder->create('dateRanges', null, ['compound' => true]);
@@ -94,29 +106,51 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType
$builder->add($dateRangesBuilder);
}
foreach ($this->requestStack->getCurrentRequest()->query->getIterator() as $key => $value) {
switch ($key) {
case 'q':
case 'checkboxes' . $key:
case $key . '_from':
case $key . '_to':
break;
if ([] !== $helper->getSingleCheckbox()) {
$singleCheckBoxBuilder = $builder->create('single_checkboxes', null, ['compound' => true]);
case 'page':
$builder->add($key, HiddenType::class, [
'data' => 1,
]);
break;
default:
$builder->add($key, HiddenType::class, [
'data' => $value,
]);
break;
foreach ($helper->getSingleCheckbox() as $name => ['label' => $label]) {
$singleCheckBoxBuilder->add($name, CheckboxType::class, ['label' => $label, 'required' => false]);
}
$builder->add($singleCheckBoxBuilder);
}
if ([] !== $helper->getUserPickers()) {
$userPickersBuilder = $builder->create('user_pickers', null, ['compound' => true]);
foreach ($helper->getUserPickers() as $name => [
'label' => $label, 'options' => $opts
]) {
$userPickersBuilder->add(
$name,
PickUserDynamicType::class,
[
'multiple' => true,
'label' => $label,
...$opts,
]
);
}
$builder->add($userPickersBuilder);
}
}
public static function buildCheckboxChoices(array $choices, array $trans = []): array
{
return array_combine(
array_map(static function ($c, $t) {
if (null !== $t) {
return $t;
}
return $c;
}, $choices, $trans),
$choices
);
}
public function buildView(FormView $view, FormInterface $form, array $options)

View File

@@ -28,9 +28,13 @@ class NotificationPresence
{
}
public function countNotificationsForClassAndEntity(string $relatedEntityClass, int $relatedEntityId): array
/**
* @param list<array{relatedEntityClass: class-string, relatedEntityId: int}> $more
* @return array{unread: int, sent: int, total: int}
*/
public function countNotificationsForClassAndEntity(string $relatedEntityClass, int $relatedEntityId, array $more = [], array $options = []): array
{
if (array_key_exists($relatedEntityClass, $this->cache) && array_key_exists($relatedEntityId, $this->cache[$relatedEntityClass])) {
if ([] === $more && array_key_exists($relatedEntityClass, $this->cache) && array_key_exists($relatedEntityId, $this->cache[$relatedEntityClass])) {
return $this->cache[$relatedEntityClass][$relatedEntityId];
}
@@ -40,21 +44,25 @@ class NotificationPresence
$counter = $this->notificationRepository->countNotificationByRelatedEntityAndUserAssociated(
$relatedEntityClass,
$relatedEntityId,
$user
$user,
$more
);
$this->cache[$relatedEntityClass][$relatedEntityId] = $counter;
if ([] === $more) {
$this->cache[$relatedEntityClass][$relatedEntityId] = $counter;
}
return $counter;
}
return ['unread' => 0, 'read' => 0];
return ['unread' => 0, 'sent' => 0, 'total' => 0];
}
/**
* @param list<array{relatedEntityClass: class-string, relatedEntityId: int}> $more
* @return array|Notification[]
*/
public function getNotificationsForClassAndEntity(string $relatedEntityClass, int $relatedEntityId): array
public function getNotificationsForClassAndEntity(string $relatedEntityClass, int $relatedEntityId, array $more = []): array
{
$user = $this->security->getUser();
@@ -62,7 +70,8 @@ class NotificationPresence
return $this->notificationRepository->findNotificationByRelatedEntityAndUserAssociated(
$relatedEntityClass,
$relatedEntityId,
$user
$user,
$more
);
}

View File

@@ -25,24 +25,30 @@ class NotificationTwigExtensionRuntime implements RuntimeExtensionInterface
{
}
public function counterNotificationFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $options = []): string
public function counterNotificationFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $more = [], array $options = []): string
{
return $environment->render(
'@ChillMain/Notification/extension_counter_notifications_for.html.twig',
[
'counter' => $this->notificationPresence->countNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId),
'counter' => $this->notificationPresence->countNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId, $more),
]
);
}
public function countNotificationsFor(string $relatedEntityClass, int $relatedEntityId, array $options = []): array
/**
* @param list<array{relatedEntityClass: class-string, relatedEntityId: int}> $more
*/
public function countNotificationsFor(string $relatedEntityClass, int $relatedEntityId, array $more = [], array $options = []): array
{
return $this->notificationPresence->countNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId);
return $this->notificationPresence->countNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId, $more);
}
public function listNotificationsFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $options = []): string
/**
* @param list<array{relatedEntityClass: class-string, relatedEntityId: int}> $more
*/
public function listNotificationsFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $more = [], array $options = []): string
{
$notifications = $this->notificationPresence->getNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId);
$notifications = $this->notificationPresence->getNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId, $more);
if ([] === $notifications) {
return '';

View File

@@ -27,6 +27,15 @@ final class NotificationRepository implements ObjectRepository
private readonly EntityRepository $repository;
private const BASE_COUNTER_SQL = <<<'SQL'
SELECT
SUM((EXISTS (SELECT 1 AS c FROM chill_main_notification_addresses_unread cmnau WHERE user_id = :userid and cmnau.notification_id = cmn.id))::int) AS unread,
SUM((cmn.sender_id = :userid)::int) AS sent,
SUM((EXISTS (SELECT 1 AS c FROM chill_main_notification_addresses_user cmnau_all WHERE user_id = :userid and cmnau_all.notification_id = cmn.id))::int) + SUM((cmn.sender_id = :userid)::int) AS total
FROM chill_main_notification cmn
SQL;
public function __construct(private readonly EntityManagerInterface $em)
{
$this->repository = $em->getRepository(Notification::class);
@@ -48,29 +57,45 @@ final class NotificationRepository implements ObjectRepository
->getSingleScalarResult();
}
public function countNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): array
/**
* @param list<array{relatedEntityClass: class-string, relatedEntityId: int}> $more
* @return array{unread: int, sent: int, total: int}
*/
public function countNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user, array $more = []): array
{
if (null === $this->notificationByRelatedEntityAndUserAssociatedStatement) {
$sql =
'SELECT
SUM((EXISTS (SELECT 1 AS c FROM chill_main_notification_addresses_unread cmnau WHERE user_id = :userid and cmnau.notification_id = cmn.id))::int) AS unread,
SUM((cmn.sender_id = :userid)::int) AS sent,
COUNT(cmn.*) AS total
FROM chill_main_notification cmn
WHERE relatedentityclass = :relatedEntityClass AND relatedentityid = :relatedEntityId AND sender_id IS NOT NULL';
$sqlParams = ['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId, 'userid' => $user->getId()];
$this->notificationByRelatedEntityAndUserAssociatedStatement =
$this->em->getConnection()->prepare($sql);
if ([] === $more) {
if (null === $this->notificationByRelatedEntityAndUserAssociatedStatement) {
$sql = self::BASE_COUNTER_SQL . ' WHERE relatedentityclass = :relatedEntityClass AND relatedentityid = :relatedEntityId AND sender_id IS NOT NULL';
$this->notificationByRelatedEntityAndUserAssociatedStatement =
$this->em->getConnection()->prepare($sql);
}
$results = $this->notificationByRelatedEntityAndUserAssociatedStatement
->executeQuery($sqlParams);
$result = $results->fetchAssociative();
$results->free();
} else {
$wheres = [];
foreach ([
['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId],
...$more
] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) {
$wheres[] = "(relatedEntityClass = :relatedEntityClass_{$k} AND relatedEntityId = :relatedEntityId_{$k})";
$sqlParams["relatedEntityClass_{$k}"] = $relClass;
$sqlParams["relatedEntityId_{$k}"] = $relId;
}
$sql = self::BASE_COUNTER_SQL . ' WHERE sender_id IS NOT NULL AND (' . implode(' OR ', $wheres) . ')';
$result = $this->em->getConnection()->fetchAssociative($sql, $sqlParams);
}
$results = $this->notificationByRelatedEntityAndUserAssociatedStatement
->executeQuery(['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId, 'userid' => $user->getId()]);
$result = $results->fetchAssociative();
$results->free();
return $result;
return array_map(fn (?int $number) => $number ?? 0, $result);
}
public function countUnreadByUser(User $user): int
@@ -164,8 +189,8 @@ final class NotificationRepository implements ObjectRepository
}
/**
* @param mixed|null $limit
* @param mixed|null $offset
* @param int|null $limit
* @param int|null $offset
*
* @return Notification[]
*/
@@ -175,13 +200,15 @@ final class NotificationRepository implements ObjectRepository
}
/**
* @param list<array{relatedEntityClass: class-string, relatedEntityId: int}> $more
* @return array|Notification[]
*/
public function findNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): array
public function findNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user, array $more): array
{
return
$this->buildQueryNotificationByRelatedEntityAndUserAssociated($relatedEntityClass, $relatedEntityId, $user)
$this->buildQueryNotificationByRelatedEntityAndUserAssociated($relatedEntityClass, $relatedEntityId, $user, $more)
->select('n')
->addOrderBy('n.date', 'DESC')
->getQuery()
->getResult();
}
@@ -219,13 +246,36 @@ final class NotificationRepository implements ObjectRepository
return Notification::class;
}
private function buildQueryNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): QueryBuilder
/**
* @param list<array{relatedEntityClass: class-string, relatedEntityId: int}> $more
*/
private function buildQueryNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user, array $more = []): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('n');
// add condition for related entity (in main arguments, and in more)
$or = $qb->expr()->orX($qb->expr()->andX(
$qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'),
$qb->expr()->eq('n.relatedEntityId', ':relatedEntityId')
));
$qb
->where($qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'))
->andWhere($qb->expr()->eq('n.relatedEntityId', ':relatedEntityId'))
->setParameter('relatedEntityClass', $relatedEntityClass)
->setParameter('relatedEntityId', $relatedEntityId);
foreach ($more as $k => ['relatedEntityClass' => $relatedClass, 'relatedEntityId' => $relatedId]) {
$or->add(
$qb->expr()->andX(
$qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass_'.$k),
$qb->expr()->eq('n.relatedEntityId', ':relatedEntityId_'.$k)
)
);
$qb
->setParameter('relatedEntityClass_'.$k, $relatedClass)
->setParameter('relatedEntityId_'.$k, $relatedId);
}
$qb
->andWhere($or)
->andWhere($qb->expr()->isNotNull('n.sender'))
->andWhere(
$qb->expr()->orX(
@@ -233,8 +283,6 @@ final class NotificationRepository implements ObjectRepository
$qb->expr()->eq('n.sender', ':user')
)
)
->setParameter('relatedEntityClass', $relatedEntityClass)
->setParameter('relatedEntityId', $relatedEntityId)
->setParameter('user', $user);
return $qb;

View File

@@ -38,6 +38,19 @@ final class PermissionsGroupRepository implements ObjectRepository
return $this->repository->findAll();
}
/**
* @return list<PermissionsGroup>
*/
public function findAllOrderedAlphabetically(): array
{
$qb = $this->repository->createQueryBuilder('pg');
return $qb->select(['pg', 'pg.name AS HIDDEN sort_name'])
->orderBy('sort_name')
->getQuery()
->getResult();
}
/**
* @param mixed|null $limit
* @param mixed|null $offset

View File

@@ -14,6 +14,8 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Regroupment;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ObjectRepository;
final class RegroupmentRepository implements ObjectRepository
@@ -59,6 +61,30 @@ final class RegroupmentRepository implements ObjectRepository
return $this->repository->findOneBy($criteria, $orderBy);
}
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function findOneByName(string $name): ?Regroupment
{
return $this->repository->createQueryBuilder('r')
->where('LOWER(r.name) = LOWER(:searched)')
->setParameter('searched', $name)
->getQuery()
->getSingleResult();
}
/**
* @return array<Regroupment>
*/
public function findRegroupmentAssociatedToNoCenter(): array
{
return $this->repository->createQueryBuilder('r')
->where('SIZE(r.centers) = 0')
->getQuery()
->getResult();
}
public function getClassName()
{
return Regroupment::class;

View File

@@ -13,6 +13,7 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\GroupCenter;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NoResultException;
@@ -73,6 +74,81 @@ final readonly class UserRepository implements UserRepositoryInterface
return $this->repository->findAll();
}
/**
* @param string $lang
*/
public function findAllAsArray(string $lang): iterable
{
$dql = sprintf(<<<'DQL'
SELECT
u.id AS id,
u.username AS username,
u.email,
u.enabled,
IDENTITY(u.civility) AS civility_id,
JSON_EXTRACT(civility.abbreviation, :lang) AS civility_abbreviation,
JSON_EXTRACT(civility.name, :lang) AS civility_name,
u.label,
mainCenter.id AS mainCenter_id,
mainCenter.name AS mainCenter_name,
IDENTITY(u.mainScope) AS mainScope_id,
JSON_EXTRACT(mainScope.name, :lang) AS mainScope_name,
IDENTITY(u.userJob) AS userJob_id,
JSON_EXTRACT(userJob.label, :lang) AS userJob_name,
currentLocation.id AS currentLocation_id,
currentLocation.name AS currentLocation_name,
mainLocation.id AS mainLocation_id,
mainLocation.name AS mainLocation_name,
u.absenceStart
FROM Chill\MainBundle\Entity\User u
LEFT JOIN u.civility civility
LEFT JOIN u.currentLocation currentLocation
LEFT JOIN u.mainLocation mainLocation
LEFT JOIN u.mainCenter mainCenter
LEFT JOIN u.mainScope mainScope
LEFT JOIN u.userJob userJob
ORDER BY u.label
DQL);
$query = $this->entityManager->createQuery($dql)
->setHydrationMode(AbstractQuery::HYDRATE_ARRAY)
->setParameter('lang', $lang)
;
foreach ($query->toIterable() as $u) {
yield $u;
}
}
public function findAllUserACLAsArray(): iterable
{
$sql = <<<'SQL'
SELECT
u.id,
u.username,
u.email,
u.label,
u.enabled,
c.id AS center_id,
c.name AS center_name,
pg.id AS permissionsGroup_id,
pg.name AS permissionsGroup_name
FROM users u
LEFT JOIN user_groupcenter ON u.id = user_groupcenter.user_id
LEFT JOIN group_centers ON user_groupcenter.groupcenter_id = group_centers.id
LEFT JOIN centers c on group_centers.center_id = c.id
LEFT JOIN permission_groups pg on group_centers.permissionsgroup_id = pg.id
ORDER BY u.username, c.name, pg.name
SQL;
$query = $this->entityManager->getConnection()->executeQuery($sql);
foreach ($query->iterateAssociative() as $u) {
yield $u;
}
}
/**
* @param mixed|null $limit
* @param mixed|null $offset

View File

@@ -14,6 +14,9 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\User;
use Doctrine\Persistence\ObjectRepository;
/**
* @template ObjectRepository<User>
*/
interface UserRepositoryInterface extends ObjectRepository
{
public function countBy(array $criteria): int;
@@ -24,20 +27,25 @@ interface UserRepositoryInterface extends ObjectRepository
public function countByUsernameOrEmail(string $pattern): int;
public function find($id, $lockMode = null, $lockVersion = null): ?User;
/**
* @return User[]
*/
public function findAll(): array;
/**
* @param mixed|null $limit
* @param mixed|null $offset
* Find a list of all users.
*
* @return User[]
* The main purpose for this method is to provide a lightweight list of all users in the database.
*
* @param string $lang The lang to display all the translatable string (no fallback if not present)
* @return iterable<array{id: int, username: string, email: string, enabled: bool, civility_id: int, civility_abbreviation: string, civility_name: string, label: string, mainCenter_id: int, mainCenter_name: string, mainScope_id: int, mainScope_name: string, userJob_id: int, userJob_name: string, currentLocation_id: int, currentLocation_name: string, mainLocation_id: int, mainLocation_name: string, absenceStart: \DateTimeImmutable}>
*/
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array;
public function findAllAsArray(string $lang): iterable;
/**
* Find a list of permissions associated to each users.
*
* The main purpose for this method is to provide a lightweight list of all permissions group and center
* associated to each user.
*
* @return iterable<array{id: int, username: string, email: string, enabled: bool, center_id: int, center_name: string, permissionsGroup_id: int, permissionsGroup_name: string}>
*/
public function findAllUserACLAsArray(): iterable;
/**
* @return array|User[]
@@ -53,8 +61,6 @@ interface UserRepositoryInterface extends ObjectRepository
public function findByUsernameOrEmail(string $pattern, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array;
public function findOneBy(array $criteria, ?array $orderBy = null): ?User;
public function findOneByUsernameOrEmail(string $pattern): ?User;
/**
@@ -67,7 +73,5 @@ interface UserRepositoryInterface extends ObjectRepository
* @param \Chill\MainBundle\Entity\User[] $amongstUsers
* @param mixed $flag
*/
public function findUsersHavingFlags($flag, array $amongstUsers = []): array;
public function getClassName(): string;
public function findUsersHavingFlags(mixed $flag, array $amongstUsers = []): array;
}

View File

@@ -42,3 +42,7 @@ form {
font-weight: 700;
margin-bottom: .375em;
}
.chill_filter_order {
background: $gray-100;
}

View File

@@ -0,0 +1,20 @@
const buildLinkCreate = function (relatedEntityClass: string, relatedEntityId: number, to: number | null, returnPath: string | null): string
{
const params = new URLSearchParams();
params.append('entityClass', relatedEntityClass);
params.append('entityId', relatedEntityId.toString());
if (null !== to) {
params.append('tos[0]', to.toString());
}
if (null !== returnPath) {
params.append('returnPath', returnPath);
}
return `/fr/notification/create?${params.toString()}`;
}
export {
buildLinkCreate,
}

View File

@@ -66,6 +66,10 @@ export default {
return appMessages.fr.the_activity;
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod':
return appMessages.fr.the_course;
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWork':
return appMessages.fr.the_action;
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument':
return appMessages.fr.the_evaluation_document;
case 'Chill\\MainBundle\\Entity\\Workflow\\EntityWorkflow':
return appMessages.fr.the_workflow;
default:
@@ -78,6 +82,10 @@ export default {
return `/fr/activity/${n.relatedEntityId}/show`
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod':
return `/fr/parcours/${n.relatedEntityId}`
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWork':
return `/fr/person/accompanying-period/work/${n.relatedEntityId}/show`
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument':
return `/fr/person/accompanying-period/work/evaluation/document/${n.relatedEntityId}/show`
case 'Chill\\MainBundle\\Entity\\Workflow\\EntityWorkflow':
return `/fr/main/workflow/${n.relatedEntityId}/show`
default:

View File

@@ -46,6 +46,7 @@ const appMessages = {
the_course: "le parcours",
the_action: "l'action",
the_evaluation: "l'évaluation",
the_evaluation_document: "le document",
the_task: "la tâche",
the_workflow: "le workflow",
StartDate: "Date d'ouverture",

View File

@@ -1,7 +1,7 @@
<template>
<span v-if="data.working_ref_status === 'to_review'" class="badge bg-danger address-details-button-warning">L'adresse de référence a été modifiée</span>
<a v-if="data.loading === false" @click.prevent="clickOrOpen" class="btn btn-misc address-details-button">
<span class="fa fa-map"></span> <!-- button -->
<a v-if="data.loading === false" @click.prevent="clickOrOpen" class="btn btn-sm address-details-button" title="Plus de détails">
<span class="fa fa-map-o"></span>
</a>
<span v-if="data.loading" class="fa fa-spin fa-spinner "></span>
<AddressModal :address="data.working_address" @update-address="onUpdateAddress" ref="address_modal"></AddressModal>

View File

@@ -1,6 +1,6 @@
<template>
<component :is="component" class="chill-entity entity-address my-3">
<component :is="component" class="chill-entity entity-address">
<component :is="component" class="address" :class="multiline">

View File

@@ -1,5 +1,5 @@
{#
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
<info@champs-libres.coop> / <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
@@ -19,7 +19,7 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title "Download export"|trans ~ export.title|trans %}
{% block js %}
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", function(e) {
@@ -27,20 +27,20 @@ window.addEventListener("DOMContentLoaded", function(e) {
query = window.location.search,
container = document.querySelector("#download_container")
;
chill.download_report(url+query, container);
});
});
</script>
{% endblock %}
{% block content %}
<div class="col-md-10">
{{ include('@ChillMain/Export/_breadcrumb.html.twig') }}
<h1>{{ export.title|trans }}</h1>
<h2>{{ "Download export"|trans }}</h2>
<div id="download_container"
data-alias="{{ alias|escape('html_attr') }}"
{%- if mime_type is defined %}
@@ -53,10 +53,24 @@ window.addEventListener("DOMContentLoaded", function(e) {
<li class="cancel"><a href="{{ chill_return_path_or('chill_main_export_index') }}" class="btn btn-cancel">{{ 'Back to the list'|trans }}</a></li>
{% if not app.request.query.has('prevent_save') %}
{% if saved_export is null %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_export_save_from_key', { alias: alias, key: app.request.query.get('key')}) }}" class="btn btn-save">{{ 'Save'|trans }}</a>
</li>
{% else %}
<li>
<div class="dropdown">
<button class="btn btn-save dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'Save'|trans }}
</button>
<ul class="dropdown-menu">
<li><a href="{{ chill_path_add_return_path('chill_main_export_save_from_key', { alias: alias, key: app.request.query.get('key')}) }}" class="dropdown-item">{{ 'Save'|trans }}</a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_edit_options_from_key', { id: saved_export.id(), key: app.request.query.get('key') }) }}" class="dropdown-item">{{ 'saved_export.Update existing'|trans }}</a></li>
</ul>
</div>
</li>
{% endif %}
{% endif %}
</ul>
</div>
{% endblock content %}
{% endblock content %}

View File

@@ -39,8 +39,13 @@
{{ 'This will eventually restrict your possibilities in filtering the data.'|trans }}</p>
<h3 class="m-3">{{ 'Center'|trans }}</h3>
{{ form_widget(form.centers.center) }}
<div class="mb-3 mt-3">
<input id="toggle-check-all" class="btn btn-misc" type= "button" onclick='uncheckAll(this)' value="{{ 'uncheck all centers'|trans|e('html_attr') }}"/>
</div>
{% if form.centers.regroupment is defined %}
<h3 class="m-3">{{ 'Pick aggregated centers'|trans }}</h3>
{{ form_widget(form.centers.regroupment) }}
@@ -53,3 +58,15 @@
</div>
{% endblock content %}
{% block js %}
<script>
const uncheckAll = () => {
const allCenters = document.getElementsByName('centers[center][]');
allCenters.forEach(checkbox => checkbox.checked = false)
}
</script>
{% endblock js %}

View File

@@ -1,65 +1,144 @@
{{ form_start(form) }}
<div class="chill_filter_order container my-4">
<div class="row">
{% if form.vars.has_search_box %}
<div class="col-md-12">
<div class="input-group mb-3">
{{ form_widget(form.q)}}
<button type="submit" class="btn btn-chill-l-gray"><i class="fa fa-search"></i></button>
</div>
</div>
{% endif %}
</div>
{% if form.dateRanges is defined %}
{% if form.dateRanges|length > 0 %}
{% for dateRangeName, _o in form.dateRanges %}
<div class="row gx-2 justify-content-center">
{% if form.dateRanges[dateRangeName].vars.label is not same as(false) %}
<div class="col-md-5">
{{ form_label(form.dateRanges[dateRangeName])}}
</div>
{% endif %}
<div class="col-md-6">
<div class="input-group mb-3">
<span class="input-group-text">{{ 'chill_calendar.From'|trans }}</span>
{{ form_widget(form.dateRanges[dateRangeName]['from']) }}
<span class="input-group-text">{{ 'chill_calendar.To'|trans }}</span>
{{ form_widget(form.dateRanges[dateRangeName]['to']) }}
</div>
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-misc"><i class="fa fa-filter"></i></button>
<div class="accordion my-3" id="filterOrderAccordion">
<h2 class="accordion-header" id="filterOrderHeading">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#filterOrderCollapse" aria-expanded="true" aria-controls="filterOrderCollapse">
<strong><i class="fa fa-fw fa-filter"></i>Filtrer la liste</strong>
</button>
</h2>
<div class="accordion-collapse collapse" id="filterOrderCollapse" aria-labelledby="filterOrderHeading" data-bs-parent="#filterOrderAccordion">
{% set btnSubmit = 0 %}
<div class="accordion-body chill_filter_order container-xxl p-5 py-2">
<div class="row my-2">
{% if form.vars.has_search_box %}
<div class="col-sm-12">
<div class="input-group">
{{ form_widget(form.q) }}
<button type="submit" class="btn btn-misc"><i class="fa fa-search"></i></button>
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% if form.dateRanges is defined %}
{% set btnSubmit = 1 %}
{% if form.dateRanges|length > 0 %}
{% for dateRangeName, _o in form.dateRanges %}
<div class="row my-2">
{% if form.dateRanges[dateRangeName].vars.label is not same as(false) %}
{{ form_label(form.dateRanges[dateRangeName])}}
{% else %}
<div class="col-sm-4 col-form-label">{{ 'filter_order.By date'|trans }}</div>
{% endif %}
<div class="col-sm-8 pt-1">
<div class="input-group">
<span class="input-group-text">{{ 'chill_calendar.From'|trans }}</span>
{{ form_widget(form.dateRanges[dateRangeName]['from']) }}
<span class="input-group-text">{{ 'chill_calendar.To'|trans }}</span>
{{ form_widget(form.dateRanges[dateRangeName]['to']) }}
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% endif %}
{% if form.checkboxes is defined %}
{% if form.checkboxes|length > 0 %}
{% for checkbox_name, options in form.checkboxes %}
<div class="row gx-0">
<div class="col-md-12">
{% for c in form['checkboxes'][checkbox_name].children %}
<div class="form-check form-check-inline">
{% if form.checkboxes is defined %}
{% set btnSubmit = 1 %}
{% if form.checkboxes|length > 0 %}
{% for checkbox_name, options in form.checkboxes %}
<div class="row my-2">
<div class="col-sm-4 col-form-label">{{ 'filter_order.By'|trans }}</div>
<div class="col-sm-8 pt-2">
{% for c in form['checkboxes'][checkbox_name].children %}
{{ form_widget(c) }}
{{ form_label(c) }}
</div>
{% endfor %}
</div>
</div>
{% if loop.last %}
<div class="row gx-0">
<div class="col-md-12">
<ul class="record_actions">
<li>
<button type="submit" class="btn btn-misc"><i class="fa fa-filter"></i></button>
</li>
</ul>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% if form.entity_choices is defined %}
{% set btnSubmit = 1 %}
{% if form.entity_choices |length > 0 %}
{% for checkbox_name, options in form.entity_choices %}
<div class="row my-2">
{% if form.entity_choices[checkbox_name].vars.label is not same as(false) %}
{{ form_label(form.entity_choices[checkbox_name])}}
{% endif %}
<div class="col-sm-8 pt-2">
{% for c in form['entity_choices'][checkbox_name].children %}
{{ form_widget(c) }}
{{ form_label(c) }}
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% if form.user_pickers is defined %}
{% set btnSubmit = 1 %}
{% if form.user_pickers.children|length > 0 %}
{% for name, options in form.user_pickers %}
<div class="row my-2">
{% if form.user_pickers[name].vars.label is not same as(false) %}
{{ form_label(form.user_pickers[name]) }}
{% else %}
{{ form_label(form.user_pickers[name].vars.label) }}
{% endif %}
<div class="col-sm-8 pt-2">
{{ form_widget(form.user_pickers[name]) }}
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% if form.single_checkboxes is defined %}
{% set btnSubmit = 1 %}
{% for name, _o in form.single_checkboxes %}
<div class="row my-2">
<div class="col-sm-4 col-form-label">{{ 'filter_order.By'|trans }}</div>
<div class="col-sm-8 pt-2">
{{ form_widget(form.single_checkboxes[name]) }}
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% if btnSubmit == 1 %}
<div class="row my-2">
<button type="submit" class="btn btn-sm btn-misc"><i class="fa fa-fw fa-filter"></i>{{ 'Filter'|trans }}</button>
</div>
{% endif %}
</div>
</div>
{% if active|length > 0 %}
<div class="activeFilters mt-3">
{% for f in active %}
<span class="badge rounded-pill bg-secondary ms-1 {{ f.position }} {{ f.name }}">
{%- if f.label != '' %}
<span class="text-dark">{{ f.label|trans }}&nbsp;: </span>
{% endif -%}
{%- if f.position == 'search_box' and f.value is not null %}
<span class="text-dark">{{ 'filter_order.search_box'|trans ~ ' :' }}</span>
{% endif -%}
{{ f.value}}{#
#}</span>
{% endfor %}
</div>
{% endif %}
<div>
</div>
</div>
{% for k,v in otherParameters %}
<input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endfor %}
{{ form_end(form) }}

View File

@@ -9,4 +9,4 @@
{{ 'notification.counter unread notifications'|trans({'unread': counter.unread }) }}
</span>
{% endif %}
</div>
</div>

View File

@@ -22,18 +22,28 @@
<div class="item-row card-body">
<p class="card-subtitle"><strong>{{ s.export.title|trans }}</strong></p>
<h2 class="card-title">{{ s.saved.title }}</h2>
<div class="createdBy">{{ 'saved_export.Created on %date%'|trans({'%date%': s.saved.createdAt|format_datetime('long', 'short')}) }}</div>
<div class="card-text my-3">
{{ s.saved.description|chill_markdown_to_html }}
</div>
<div>
<ul class="record_actions">
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': s.saved.id }) }}" class="btn btn-delete"></a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_edit', {'id': s.saved.id }) }}" class="btn btn-edit"></a></li>
<li><a href="{{ path('chill_main_export_generate_from_saved', { id: s.saved.id }) }}" class="btn btn-action"><i class="fa fa-cog"></i></a></li>
<li>
<div class="dropdown">
<button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'Actions'|trans }}
</button>
<ul class="dropdown-menu">
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_edit', {'id': s.saved.id }) }}" class="dropdown-item"><i class="fa fa-pencil"></i> {{ 'saved_export.update_title_and_description'|trans }}</a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_new', {'alias': s.saved.exportAlias,'from_saved': s.saved.id }) }}" class="dropdown-item"><i class="fa fa-pencil"></i> {{ 'saved_export.update_filters_aggregators_and_execute'|trans }}</a></li>
<li><a href="{{ path('chill_main_export_generate_from_saved', { id: s.saved.id }) }}" class="dropdown-item"><i class="fa fa-cog"></i> {{ 'saved_export.execute'|trans }}</a></li>
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': s.saved.id }) }}" class="dropdown-item"><i class="fa fa-trash"></i> {{ 'Delete'|trans }}</a></li>
</ul>
</div>
</li>
</ul>
</div>
</div>
@@ -54,13 +64,13 @@
<div class="item-row card-body">
<p class="card-subtitle"><strong>{{ s.export.title|trans }}</strong></p>
<h2 class="card-title">{{ s.saved.title }}</h2>
<div class="createdBy">{{ 'saved_export.Created on %date%'|trans({'%date%': s.saved.createdAt|format_datetime('long', 'short')}) }}</div>
<div class="card-text my-3">
{{ s.saved.description|chill_markdown_to_html }}
</div>
<div>
<ul class="record_actions">
<li><a href="{{ chill_path_add_return_path('chill_main_export_saved_delete', {'id': s.saved.id }) }}" class="btn btn-delete"></a></li>

View File

@@ -98,6 +98,18 @@
<li class='cancel'>
<a href="{{ path('chill_main_admin_central') }}" class="btn btn-cancel">{{'Back to the admin'|trans}}</a>
</li>
<li>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa fa-download"></i>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ path('chill_main_users_export_list') }}">{{ 'admin.users.export_list_csv'|trans }}</a></li>
<li><a class="dropdown-item" href="{{ path('chill_main_users_export_permissions') }}">{{ 'admin.users.export_permissions_csv'|trans }}</a></li>
</ul>
</div>
</li>
<li>
<a href="{{ path('chill_crud_admin_user_new') }}" class="btn btn-create">{{ 'Create'|trans }}</a>
</li>

View File

@@ -22,7 +22,6 @@ interface LocalMenuBuilderInterface
{
/**
* @param $menuId
* @param MenuItem $menu
* @param TParams $parameters
*/
public function buildMenu($menuId, MenuItem $menu, array $parameters);

View File

@@ -15,7 +15,7 @@ use Symfony\Component\Serializer\Annotation as Serializer;
class SearchApiResult
{
private $result;
private mixed $result;
public function __construct(private readonly float $relevance)
{
@@ -37,7 +37,7 @@ class SearchApiResult
return $this->result;
}
public function setResult($result): self
public function setResult(mixed $result): self
{
$this->result = $result;

View File

@@ -170,10 +170,6 @@ class AuthorizationHelper implements AuthorizationHelperInterface
/**
* Return all reachable scope for a given user, center and role.
*
* @param Center|Center[] $center
*
* @return array|Scope[]
*/
public function getReachableScopes(UserInterface $user, string $role, Center|array $center): array
{

View File

@@ -25,7 +25,8 @@ interface AuthorizationHelperForCurrentUserInterface
public function getReachableCenters(string $role, ?Scope $scope = null): array;
/**
* @param array|Center|Center[] $center
* @param list<Center>|Center $center
* @return list<Scope>
*/
public function getReachableScopes(string $role, $center): array;
public function getReachableScopes(string $role, array|Center $center): array;
}

View File

@@ -26,7 +26,8 @@ interface AuthorizationHelperInterface
public function getReachableCenters(UserInterface $user, string $role, ?Scope $scope = null): array;
/**
* @param Center|list<Center> $center
* @param Center|array<Center> $center
* @return list<Scope>
*/
public function getReachableScopes(UserInterface $user, string $role, Center|array $center): array;
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\AddressGeographicalUnit;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
final readonly class CollateAddressWithReferenceOrPostalCode implements CollateAddressWithReferenceOrPostalCodeInterface
{
private const LOG_PREFIX = '[collate addresses] ';
/**
* For the address having an "invented" postal code, find the postal code "reference" with the same code,
* and the most similar name. When two reference code match, we add
*
* This query intentionally includes also address with reference, as the reference may be wrong.
*/
private const FORCE_ORIGINAL_POSTAL_CODE = <<<'SQL'
WITH recollate AS (
SELECT * FROM (
SELECT cma.id AS address_id, cmpc.id, cmpc.label, cmpc.code, cmpc_reference.id AS cmpc_reference_id, cmpc_reference.label, cmpc_reference.code,
RANK() OVER (PARTITION BY cma.id ORDER BY SIMILARITY(cmpc.label, cmpc_reference.label) DESC, cmpc_reference.id ASC) AS ranked
FROM
chill_main_address cma JOIN chill_main_postal_code cmpc on cma.postcode_id = cmpc.id,
chill_main_postal_code cmpc_reference
WHERE
-- use only postal code which are reference
cmpc_reference.id != cmpc.id AND cmpc_reference.origin = 0
-- only where cmpc is created manually
AND cmpc.origin != 0
-- only when postal code match
AND TRIM(REPLACE(LOWER(cmpc.code), ' ', '')) = LOWER(cmpc_reference.code)
AND cmpc.country_id = cmpc_reference.country_id
AND cma.id > :since_id -- to set the first id
) sq
WHERE ranked = 1)
UPDATE chill_main_address SET postcode_id = cmpc_reference_id FROM recollate WHERE recollate.address_id = chill_main_address.id;
SQL;
/**
* associate the address with the most similar address reference.
*
* This query intentionally ignores the existing addressreference_id, to let fixing the address match the
* most similar address reference.
*/
private const FORCE_MOST_SIMILAR_ADDRESS_REFERENCE = <<<'SQL'
WITH recollate AS (
SELECT * FROM (
SELECT cma.id AS address_id, cma.streetnumber, cma.street, cmpc.code, cmpc.label, cmar.id AS address_reference_id, cmar.streetnumber, cmar.street, cmpc_reference.code, cmpc_reference.label,
similarity(cma.street, cmar.street),
RANK() OVER (PARTITION BY cma.id ORDER BY SIMILARITY (cma.street, cmar.street) DESC, SIMILARITY (cma.streetnumber, cmar.streetnumber), cmar.id ASC) AS ranked
FROM
chill_main_address cma
JOIN chill_main_postal_code cmpc on cma.postcode_id = cmpc.id,
chill_main_address_reference cmar JOIN chill_main_postal_code cmpc_reference ON cmar.postcode_id = cmpc_reference.id
WHERE
-- only if cmpc is a reference (must be matched before executing this query)
cma.postcode_id = cmar.postcode_id
-- join cmpc to cma
AND SIMILARITY(LOWER(cma.street), LOWER(cmar.street)) > 0.6 AND LOWER(cma.streetnumber) = LOWER(cmar.streetnumber)
-- only addresses which match the address reference - let the user decide if the reference has changed
AND cma.refstatus = 'match'
-- only the most recent
AND cma.id > :since_id
) AS sq
WHERE ranked = 1
)
UPDATE chill_main_address SET addressreference_id = recollate.address_reference_id FROM recollate WHERE chill_main_address.id = recollate.address_id;
SQL;
/**
* Update the point's address with the:
*
* - address reference point, if the address match the reference with sufficient similarity
* - or the postcal code center
*/
private const UPDATE_POINT = <<<'SQL'
WITH address_geom AS (
SELECT cma.id AS address_id, COALESCE(cmar.point, cmpc.center) AS point
FROM chill_main_address cma
LEFT JOIN chill_main_address_reference cmar ON cma.addressreference_id = cmar.id AND similarity(cma.street, cmar.street) > 0.6 AND LOWER(cma.streetnumber) = LOWER(cmar.streetnumber)
LEFT JOIN chill_main_postal_code cmpc ON cma.postcode_id = cmpc.id
WHERE cma.id > :since_id
)
UPDATE chill_main_address SET point = address_geom.point FROM address_geom WHERE address_geom.address_id = chill_main_address.id
SQL;
private const MAX_ADDRESS_ID = <<<'SQL'
SELECT MAX(id) AS max_id FROM chill_main_address;
SQL;
public function __construct(
private Connection $connection,
private LoggerInterface $logger,
) {
}
/**
* @throws \Throwable
*/
public function __invoke(int $sinceId = 0): int
{
try {
[
$postCodeSetReferenceFromMostSimilar,
$addressReferenceMatch,
$pointUpdates,
$lastId,
] = $this->connection->transactional(function () use ($sinceId) {
$postCodeSetReferenceFromMostSimilar = $this->connection->executeStatement(self::FORCE_ORIGINAL_POSTAL_CODE, ['since_id' => $sinceId]);
$addressReferenceMatch = $this->connection->executeStatement(self::FORCE_MOST_SIMILAR_ADDRESS_REFERENCE, ['since_id' => $sinceId]);
$pointUpdates = $this->connection->executeStatement(self::UPDATE_POINT, ['since_id' => $sinceId]);
$lastId = $this->connection->fetchOne(self::MAX_ADDRESS_ID);
return [
$postCodeSetReferenceFromMostSimilar,
$addressReferenceMatch,
$pointUpdates,
$lastId,
];
});
} catch (\Throwable $e) {
$this->logger->error(self::LOG_PREFIX . "error while re-collating addresses", [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
$this->logger->info(self::LOG_PREFIX . "Collate the addresses with reference", [
'set_postcode_from_most_similar' => $postCodeSetReferenceFromMostSimilar,
'address_reference_match' => $addressReferenceMatch,
'point_update' => $pointUpdates,
'since_id' => $sinceId,
'last_id' => $lastId,
]);
return $lastId;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\AddressGeographicalUnit;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Symfony\Component\Clock\ClockInterface;
final readonly class CollateAddressWithReferenceOrPostalCodeCronJob implements CronJobInterface
{
private const LAST_MAX_ID = 'last-max-id';
public function __construct(
private ClockInterface $clock,
private CollateAddressWithReferenceOrPostalCodeInterface $collateAddressWithReferenceOrPostalCode,
) {
}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
if (null === $cronJobExecution) {
return true;
}
$now = $this->clock->now();
return $now->sub(new \DateInterval('PT6H')) > $cronJobExecution->getLastStart();
}
public function getKey(): string
{
return 'collate-address';
}
public function run(array $lastExecutionData): null|array
{
$maxId = ($this->collateAddressWithReferenceOrPostalCode)($lastExecutionData[self::LAST_MAX_ID] ?? 0);
return [self::LAST_MAX_ID => $maxId];
}
}

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\Service\AddressGeographicalUnit;
interface CollateAddressWithReferenceOrPostalCodeInterface
{
/**
* @throws \Throwable
*/
public function __invoke(int $sinceId = 0): int;
}

View File

@@ -46,8 +46,10 @@ class RefreshAddressToGeographicalUnitMaterializedViewCronJob implements CronJob
return 'refresh-materialized-view-address-to-geog-units';
}
public function run(): void
public function run(array $lastExecutionData): null|array
{
$this->connection->executeQuery('REFRESH MATERIALIZED VIEW view_chill_main_address_geographical_unit');
return null;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\EntityInfo;
use Doctrine\DBAL\Connection;
class ViewEntityInfoManager
{
public function __construct(
/**
* @var ViewEntityInfoProviderInterface[]
*/
private readonly iterable $vienEntityInfoProviders,
private readonly Connection $connection,
) {
}
public function synchronizeOnDB(): void
{
$this->connection->transactional(function (Connection $conn): void {
foreach ($this->vienEntityInfoProviders as $viewProvider) {
foreach ($this->createOrReplaceViewSQL($viewProvider, $viewProvider->getViewName()) as $sql) {
$conn->executeQuery($sql);
}
}
});
}
/**
* @return array<string>
*/
private function createOrReplaceViewSQL(ViewEntityInfoProviderInterface $viewProvider, string $viewName): array
{
return [
"DROP VIEW IF EXISTS {$viewName}",
sprintf("CREATE VIEW {$viewName} AS %s", $viewProvider->getViewQuery())
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\EntityInfo;
interface ViewEntityInfoProviderInterface
{
public function getViewQuery(): string;
public function getViewName(): string;
}

View File

@@ -64,11 +64,14 @@ class RollingDate
final public const T_YEAR_PREVIOUS_START = 'year_previous_start';
/**
* @param string|self::T_* $roll
* @param DateTimeImmutable|null $fixedDate Only to insert if $roll equals @see{self::T_FIXED_DATE}
* @param DateTimeImmutable $pivotDate Will be "now" if null is given
*/
public function __construct(private readonly string $roll, private readonly ?DateTimeImmutable $fixedDate = null, private readonly DateTimeImmutable $pivotDate = new DateTimeImmutable('now'))
{
public function __construct(
private readonly string $roll,
private readonly ?\DateTimeImmutable $fixedDate = null,
private readonly DateTimeImmutable $pivotDate = new DateTimeImmutable('now')
) {
}
public function getFixedDate(): ?DateTimeImmutable

View File

@@ -18,8 +18,12 @@ use UnexpectedValueException;
class RollingDateConverter implements RollingDateConverterInterface
{
public function convert(RollingDate $rollingDate): DateTimeImmutable
public function convert(?RollingDate $rollingDate): ?DateTimeImmutable
{
if (null === $rollingDate) {
return null;
}
switch ($rollingDate->getRoll()) {
case RollingDate::T_MONTH_CURRENT_START:
return $this->toBeginOfMonth($rollingDate->getPivotDate());

View File

@@ -15,5 +15,9 @@ use DateTimeImmutable;
interface RollingDateConverterInterface
{
public function convert(RollingDate $rollingDate): DateTimeImmutable;
/**
* @param RollingDate|null $rollingDate
* @return ($rollingDate is null ? null : DateTimeImmutable)
*/
public function convert(?RollingDate $rollingDate): ?DateTimeImmutable;
}

View File

@@ -0,0 +1,91 @@
<?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\Templating\Listing;
use Chill\MainBundle\Templating\Entity\UserRender;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyAccess\PropertyPathInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class FilterOrderGetActiveFilterHelper
{
public function __construct(
private TranslatorInterface $translator,
private PropertyAccessorInterface $propertyAccessor,
private UserRender $userRender,
) {
}
/**
* Return all the data required to display the active filters
*
* @return array<array{label: string, value: string, position: string, name: string}>
*/
public function getActiveFilters(FilterOrderHelper $filterOrderHelper): array
{
$result = [];
if ($filterOrderHelper->hasSearchBox() && '' !== $filterOrderHelper->getQueryString()) {
$result[] = ['label' => '', 'value' => $filterOrderHelper->getQueryString(), 'position' => FilterOrderPositionEnum::SearchBox->value, 'name' => 'q'];
}
foreach ($filterOrderHelper->getDateRanges() as $name => ['label' => $label]) {
$base = ['position' => FilterOrderPositionEnum::DateRange->value, 'name' => $name, 'label' => (string)$label];
if (null !== ($from = $filterOrderHelper->getDateRangeData($name)['from'] ?? null)) {
$result[] = ['value' => $this->translator->trans('filter_order.by_date.From', ['from_date' => $from]), ...$base];
}
if (null !== ($to = $filterOrderHelper->getDateRangeData($name)['to'] ?? null)) {
$result[] = ['value' => $this->translator->trans('filter_order.by_date.To', ['to_date' => $to]), ...$base];
}
}
foreach ($filterOrderHelper->getCheckboxes() as $name => ['choices' => $choices, 'trans' => $trans]) {
$translatedChoice = array_combine($choices, [...$trans]);
foreach ($filterOrderHelper->getCheckboxData($name) as $keyChoice) {
$result[] = ['value' => $this->translator->trans($translatedChoice[$keyChoice]), 'label' => '', 'position' => FilterOrderPositionEnum::Checkboxes->value, 'name' => $name];
}
}
foreach ($filterOrderHelper->getEntityChoices() as $name => ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options]) {
foreach ($filterOrderHelper->getEntityChoiceData($name) as $selected) {
if (is_callable($options['choice_label'])) {
$value = call_user_func($options['choice_label'], $selected);
} elseif ($options['choice_label'] instanceof PropertyPathInterface || is_string($options['choice_label'])) {
$value = $this->propertyAccessor->getValue($selected, $options['choice_label']);
} else {
if (!$selected instanceof \Stringable) {
throw new \UnexpectedValueException(sprintf("we are not able to transform the value of %s to a string. Implements \\Stringable or add a 'choice_label' option to the filterFormBuilder", $selected::class));
}
$value = (string)$selected;
}
$result[] = ['value' => $this->translator->trans($value), 'label' => $label, 'position' => FilterOrderPositionEnum::EntityChoice->value, 'name' => $name];
}
}
foreach ($filterOrderHelper->getUserPickers() as $name => ['label' => $label, 'options' => $options]) {
foreach ($filterOrderHelper->getUserPickerData($name) as $user) {
$result[] = ['value' => $this->userRender->renderString($user, []), 'label' => (string) $label, 'position' => FilterOrderPositionEnum::UserPicker->value, 'name' => $name];
}
}
foreach ($filterOrderHelper->getSingleCheckbox() as $name => ['label' => $label]) {
if (true === $filterOrderHelper->getSingleCheckboxData($name)) {
$result[] = ['label' => '', 'value' => $this->translator->trans($label), 'position' => FilterOrderPositionEnum::SingleCheckbox->value, 'name' => $name];
}
}
return $result;
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Templating\Listing;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\Listing\FilterOrderType;
use DateTimeImmutable;
use Symfony\Component\Form\FormFactoryInterface;
@@ -18,15 +19,19 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use function array_merge;
use function count;
class FilterOrderHelper
final class FilterOrderHelper
{
private array $checkboxes = [];
/**
* @var array<string, array{label: string}>
*/
private array $singleCheckbox = [];
private array $dateRanges = [];
private ?string $formName = 'f';
public const FORM_NAME = 'f';
private array $formOptions = [];
@@ -36,20 +41,63 @@ class FilterOrderHelper
private ?array $submitted = null;
public function __construct(private readonly FormFactoryInterface $formFactory, private readonly RequestStack $requestStack)
{
/**
* @var array<string, array{label: string, choices: array, options: array}>
*/
private array $entityChoices = [];
/**
* @var array<string, array{label: string, options: array}>
*/
private array $userPickers = [];
public function __construct(
private readonly FormFactoryInterface $formFactory,
private readonly RequestStack $requestStack,
) {
}
public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = []): self
public function addSingleCheckbox(string $name, string $label): self
{
$missing = count($choices) - count($trans) - 1;
$this->singleCheckbox[$name] = ['label' => $label];
return $this;
}
/**
* @param class-string $class
*/
public function addEntityChoice(string $name, string $class, string $label, array $choices, array $options = []): self
{
$this->entityChoices[$name] = ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options];
return $this;
}
public function getEntityChoices(): array
{
return $this->entityChoices;
}
public function addUserPicker(string $name, ?string $label = null, array $options = []): self
{
$this->userPickers[$name] = ['label' => $label, 'options' => $options];
return $this;
}
public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = [], array $options = []): self
{
if ([] === $trans) {
$trans = $choices;
}
$this->checkboxes[$name] = [
'choices' => $choices, 'default' => $default,
'trans' => array_merge(
$trans,
0 < $missing ?
array_fill(0, $missing, null) : []
),
'choices' => $choices,
'default' => $default,
'trans' => $trans,
...$options,
];
return $this;
@@ -65,7 +113,7 @@ class FilterOrderHelper
public function buildForm(): FormInterface
{
return $this->formFactory
->createNamed($this->formName, $this->formType, $this->getDefaultData(), array_merge([
->createNamed(self::FORM_NAME, $this->formType, $this->getDefaultData(), array_merge([
'helper' => $this,
'method' => 'GET',
'csrf_protection' => false,
@@ -73,18 +121,74 @@ class FilterOrderHelper
->handleRequest($this->requestStack->getCurrentRequest());
}
public function getUserPickers(): array
{
return $this->userPickers;
}
/**
* @return list<User>
*/
public function getUserPickerData(string $name): array
{
return $this->getFormData()['user_pickers'][$name];
}
public function hasCheckboxData(string $name): bool
{
return array_key_exists($name, $this->checkboxes);
}
public function getCheckboxData(string $name): array
{
return $this->getFormData()['checkboxes'][$name];
}
public function hasSingleCheckboxData(string $name): bool
{
return array_key_exists($name, $this->singleCheckbox);
}
public function getSingleCheckboxData(string $name): ?bool
{
return $this->getFormData()['single_checkboxes'][$name];
}
public function hasEntityChoice(string $name): bool
{
return array_key_exists($name, $this->entityChoices);
}
public function getEntityChoiceData($name): mixed
{
return $this->getFormData()['entity_choices'][$name];
}
public function getCheckboxes(): array
{
return $this->checkboxes;
}
public function hasCheckBox(string $name): bool
{
return array_key_exists($name, $this->checkboxes);
}
/**
* @return array<'to': DateTimeImmutable, 'from': DateTimeImmutable>
* @return array<string, array{label: string}>
*/
public function getSingleCheckbox(): array
{
return $this->singleCheckbox;
}
public function hasDateRangeData(string $name): bool
{
return array_key_exists($name, $this->dateRanges);
}
/**
* @return array{to: ?DateTimeImmutable, from: ?DateTimeImmutable}
*/
public function getDateRangeData(string $name): array
{
@@ -115,7 +219,13 @@ class FilterOrderHelper
private function getDefaultData(): array
{
$r = [];
$r = [
'checkboxes' => [],
'dateRanges' => [],
'single_checkboxes' => [],
'entity_choices' => [],
'user_pickers' => []
];
if ($this->hasSearchBox()) {
$r['q'] = '';
@@ -130,6 +240,18 @@ class FilterOrderHelper
$r['dateRanges'][$name]['to'] = $defaults['to'];
}
foreach ($this->singleCheckbox as $name => $c) {
$r['single_checkboxes'][$name] = false;
}
foreach ($this->entityChoices as $name => $c) {
$r['entity_choices'][$name] = ($c['options']['multiple'] ?? true) ? [] : null;
}
foreach ($this->userPickers as $name => $u) {
$r['user_pickers'][$name] = ($u['options']['multiple'] ?? true) ? [] : null;
}
return $r;
}

View File

@@ -14,6 +14,8 @@ namespace Chill\MainBundle\Templating\Listing;
use DateTimeImmutable;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class FilterOrderHelperBuilder
{
@@ -23,10 +25,32 @@ class FilterOrderHelperBuilder
private ?array $searchBoxFields = null;
/**
* @var array<string, array{label: string}>
*/
private array $singleCheckboxes = [];
/**
* @var array<string, array{label: string, class: class-string, choices: array, options: array}>
*/
private array $entityChoices = [];
/**
* @var array<string, array{label: string, options: array}>
*/
private array $userPickers = [];
public function __construct(private readonly FormFactoryInterface $formFactory, private readonly RequestStack $requestStack)
{
}
public function addSingleCheckbox(string $name, string $label): self
{
$this->singleCheckboxes[$name] = ['label' => $label];
return $this;
}
public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = []): self
{
$this->checkboxes[$name] = ['choices' => $choices, 'default' => $default, 'trans' => $trans];
@@ -34,6 +58,16 @@ class FilterOrderHelperBuilder
return $this;
}
/**
* @param class-string $class
*/
public function addEntityChoice(string $name, string $label, string $class, array $choices, ?array $options = []): self
{
$this->entityChoices[$name] = ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options];
return $this;
}
public function addDateRange(string $name, ?string $label = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null): self
{
$this->dateRanges[$name] = ['from' => $from, 'to' => $to, 'label' => $label];
@@ -48,11 +82,18 @@ class FilterOrderHelperBuilder
return $this;
}
public function addUserPicker(string $name, ?string $label = null, ?array $options = []): self
{
$this->userPickers[$name] = ['label' => $label, 'options' => $options];
return $this;
}
public function build(): FilterOrderHelper
{
$helper = new FilterOrderHelper(
$this->formFactory,
$this->requestStack
$this->requestStack,
);
$helper->setSearchBox($this->searchBoxFields);
@@ -67,6 +108,18 @@ class FilterOrderHelperBuilder
$helper->addCheckbox($name, $choices, $default, $trans);
}
foreach (
$this->singleCheckboxes as $name => ['label' => $label]
) {
$helper->addSingleCheckbox($name, $label);
}
foreach (
$this->entityChoices as $name => ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options]
) {
$helper->addEntityChoice($name, $class, $label, $choices, $options);
}
foreach (
$this->dateRanges as $name => [
'from' => $from,
@@ -77,6 +130,17 @@ class FilterOrderHelperBuilder
$helper->addDateRange($name, $label, $from, $to);
}
foreach (
$this->userPickers as $name => [
'label' => $label,
'options' => $options
]
) {
$helper->addUserPicker($name, $label, $options);
}
return $helper;
}
}

View File

@@ -13,6 +13,8 @@ namespace Chill\MainBundle\Templating\Listing;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class FilterOrderHelperFactory implements FilterOrderHelperFactoryInterface
{

View File

@@ -0,0 +1,22 @@
<?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\Templating\Listing;
enum FilterOrderPositionEnum: string
{
case SearchBox = 'search_box';
case Checkboxes = 'checkboxes';
case DateRange = 'date_range';
case EntityChoice = 'entity_choice';
case SingleCheckbox = 'single_checkbox';
case UserPicker = 'user_picker';
}

View File

@@ -11,13 +11,24 @@ declare(strict_types=1);
namespace Chill\MainBundle\Templating\Listing;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class Templating extends AbstractExtension
{
public function getFilters()
public function __construct(
private readonly RequestStack $requestStack,
private readonly FilterOrderGetActiveFilterHelper $filterOrderGetActiveFilterHelper,
) {
}
public function getFilters(): array
{
return [
new TwigFilter('chill_render_filter_order_helper', $this->renderFilterOrderHelper(...), [
@@ -26,16 +37,42 @@ class Templating extends AbstractExtension
];
}
/**
* @throws SyntaxError
* @throws RuntimeError
* @throws LoaderError
*/
public function renderFilterOrderHelper(
Environment $environment,
FilterOrderHelper $helper,
?string $template = '@ChillMain/FilterOrder/base.html.twig',
?array $options = []
) {
): string {
$otherParameters = [];
foreach ($this->requestStack->getCurrentRequest()->query->getIterator() as $key => $value) {
switch ($key) {
case FilterOrderHelper::FORM_NAME:
break;
case PaginatorFactory::DEFAULT_CURRENT_PAGE_KEY:
// when filtering, go back to page 1
$otherParameters[PaginatorFactory::DEFAULT_CURRENT_PAGE_KEY] = 1;
break;
default:
$otherParameters[$key] = $value;
break;
}
}
return $environment->render($template, [
'helper' => $helper,
'active' => $this->filterOrderGetActiveFilterHelper->getActiveFilters($helper),
'form' => $helper->buildForm()->createView(),
'options' => $options,
'otherParameters' => $otherParameters,
]);
}
}

View File

@@ -0,0 +1,87 @@
<?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 Cron;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Cron\CronManager;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Repository\CronJobExecutionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
class CronJobDatabaseInteractionTest extends KernelTestCase
{
use ProphecyTrait;
private EntityManagerInterface $entityManager;
private CronJobExecutionRepository $cronJobExecutionRepository;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::$container->get(EntityManagerInterface::class);
$this->cronJobExecutionRepository = self::$container->get(CronJobExecutionRepository::class);
}
public function testCompleteLifeCycle(): void
{
$cronjob = $this->prophesize(CronJobInterface::class);
$cronjob->canRun(null)->willReturn(true);
$cronjob->canRun(Argument::type(CronJobExecution::class))->willReturn(true);
$cronjob->getKey()->willReturn('test-with-data');
$cronjob->run([])->willReturn(['test' => 'execution-0']);
$cronjob->run(['test' => 'execution-0'])->willReturn(['test' => 'execution-1']);
$cronjob->run([])->shouldBeCalledOnce();
$cronjob->run(['test' => 'execution-0'])->shouldBeCalledOnce();
$manager = new CronManager(
$this->cronJobExecutionRepository,
$this->entityManager,
[$cronjob->reveal()],
new NullLogger()
);
// run a first time
$manager->run();
// run a second time
$manager->run();
}
}
class JobWithReturn implements CronJobInterface
{
public function canRun(?CronJobExecution $cronJobExecution): bool
{
return true;
}
public function getKey(): string
{
return 'with-data';
}
public function run(array $lastExecutionData): null|array
{
return ['data' => 'test'];
}
}

View File

@@ -40,7 +40,7 @@ final class CronManagerTest extends TestCase
$jobToExecute = $this->prophesize(CronJobInterface::class);
$jobToExecute->getKey()->willReturn('to-exec');
$jobToExecute->canRun(Argument::type(CronJobExecution::class))->willReturn(true);
$jobToExecute->run()->shouldBeCalled();
$jobToExecute->run([])->shouldBeCalled();
$executions = [
['key' => $jobOld1->getKey(), 'lastStart' => new DateTimeImmutable('yesterday'), 'lastEnd' => new DateTimeImmutable('1 hours ago'), 'lastStatus' => CronJobExecution::SUCCESS],
@@ -64,7 +64,7 @@ final class CronManagerTest extends TestCase
$jobAlreadyExecuted = new JobCanRun('k');
$jobNeverExecuted = $this->prophesize(CronJobInterface::class);
$jobNeverExecuted->getKey()->willReturn('never-executed');
$jobNeverExecuted->run()->shouldBeCalled();
$jobNeverExecuted->run([])->shouldBeCalled();
$jobNeverExecuted->canRun(null)->willReturn(true);
$executions = [
@@ -86,7 +86,7 @@ final class CronManagerTest extends TestCase
$jobAlreadyExecuted = new JobCanRun('k');
$jobNeverExecuted = $this->prophesize(CronJobInterface::class);
$jobNeverExecuted->getKey()->willReturn('never-executed');
$jobNeverExecuted->run()->shouldBeCalled();
$jobNeverExecuted->run([])->shouldBeCalled();
$jobNeverExecuted->canRun(null)->willReturn(true);
$executions = [
@@ -175,8 +175,9 @@ class JobCanRun implements CronJobInterface
return $this->key;
}
public function run(): void
public function run(array $lastExecutionData): null|array
{
return null;
}
}
@@ -192,7 +193,8 @@ class JobCannotRun implements CronJobInterface
return 'job-b';
}
public function run(): void
public function run(array $lastExecutionData): null|array
{
return null;
}
}

View File

@@ -0,0 +1,68 @@
<?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 Services\AddressGeographicalUnit;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Service\AddressGeographicalUnit\CollateAddressWithReferenceOrPostalCodeCronJob;
use Chill\MainBundle\Service\AddressGeographicalUnit\CollateAddressWithReferenceOrPostalCodeInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
/**
* @internal
* @coversNothing
*/
class CollateAddressWithReferenceOrPostalCodeCronJobTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataCanRun
*/
public function testCanRun(\DateTimeImmutable $now, ?\DateTimeImmutable $lastExecution, bool $expected): void
{
$execution = match ($lastExecution) {
null => null,
default => (new CronJobExecution('collate-address'))->setLastStart($lastExecution),
};
$clock = new MockClock($now);
$collator = $this->prophesize(CollateAddressWithReferenceOrPostalCodeInterface::class);
$job = new CollateAddressWithReferenceOrPostalCodeCronJob($clock, $collator->reveal());
self::assertEquals($expected, $job->canRun($execution));
}
public function testRun(): void
{
$clock = new MockClock();
$collator = $this->prophesize(CollateAddressWithReferenceOrPostalCodeInterface::class);
$collator->__invoke(0)->shouldBeCalledOnce();
$collator->__invoke(0)->willReturn(1);
$job = new CollateAddressWithReferenceOrPostalCodeCronJob($clock, $collator->reveal());
$actual = $job->run(['last-max-id' => 0]);
self::assertEquals(['last-max-id' => 1], $actual);
}
public static function provideDataCanRun(): iterable
{
yield [new \DateTimeImmutable('2023-07-10T12:00:00'), new \DateTimeImmutable('2023-07-10T11:00:00'), false];
yield [new \DateTimeImmutable('2023-07-10T12:00:00'), new \DateTimeImmutable('2023-07-10T05:00:00'), true];
yield [new \DateTimeImmutable('2023-07-10T12:00:00'), new \DateTimeImmutable('2023-07-01T12:00:00'), true];
yield [new \DateTimeImmutable('2023-07-10T12:00:00'), null, true];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Services\AddressGeographicalUnit;
use Chill\MainBundle\Service\AddressGeographicalUnit\CollateAddressWithReferenceOrPostalCode;
use Doctrine\DBAL\Connection;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
class CollateAddressWithReferenceOrPostalCodeTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
$this->connection = self::$container->get(Connection::class);
}
public function testRun(): void
{
$collator = new CollateAddressWithReferenceOrPostalCode(
$this->connection,
new NullLogger()
);
$result = $collator(0);
self::assertGreaterThan(0, $result);
}
}

View File

@@ -1,6 +1,9 @@
parameters:
# cl_chill_main.example.class: Chill\MainBundle\Example
imports:
- ./services/clock.yaml
services:
_defaults:
autowire: true
@@ -118,3 +121,7 @@ services:
lazy: true
arguments:
$jobs: !tagged_iterator chill_main.cron_job
Chill\MainBundle\Service\EntityInfo\ViewEntityInfoManager:
arguments:
$vienEntityInfoProviders: !tagged_iterator chill_main.entity_info_provider

View File

@@ -0,0 +1,4 @@
# temporary, waiting for symfony 6.0 to load clock
services:
Symfony\Component\Clock\NativeClock: ~
Symfony\Component\Clock\ClockInterface: '@Symfony\Component\Clock\NativeClock'

View File

@@ -67,3 +67,7 @@ services:
autowire: true
tags:
- {name: console.command }
Chill\MainBundle\Command\SynchronizeEntityInfoViewsCommand:
tags:
- {name: console.command}

View File

@@ -37,3 +37,6 @@ services:
Chill\MainBundle\Controller\RegroupmentController:
autowire: true
autoconfigure: true
Chill\MainBundle\Controller\UserExportController:
tags: ['controller.service_arguments']

View File

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

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\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230711152947 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add data to ';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_cronjob_execution ADD lastExecutionData JSONB DEFAULT \'{}\'::jsonb NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_cronjob_execution DROP COLUMN lastExecutionData');
}
}

View File

@@ -54,3 +54,12 @@ duration:
few {# minutes}
other {# minutes}
}
filter_order:
by_date:
From: Depuis le {from_date, date, long}
To: Jusqu'au {to_date, date, long}
By: Filtrer par
Search: Chercher dans la liste
By date: Filtrer par date
search_box: Filtrer par contenu

View File

@@ -42,6 +42,7 @@ by_user: "par "
lifecycleUpdate: Evenements de création et mise à jour
address_fields: Données liées à l'adresse
Datas: Données
No title: Aucun titre
inactive: inactif
@@ -285,6 +286,8 @@ The export will contains only data from the picked centers.: L'export ne contien
This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les centres choisis.
Go to export options: Vers la préparation de l'export
Pick aggregated centers: Regroupement de centres
uncheck all centers: Désélectionner tous les centres
check all centers: Sélectionner tous les centres
# export creation step 'export' : choose aggregators, filtering and formatter
Formatter: Mise en forme
Choose the formatter: Choisissez le format d'export voulu.
@@ -564,6 +567,7 @@ export:
_as_string: Adresse formattée
confidential: Adresse confidentielle ?
isNoAddress: Adresse incomplète ?
steps: Escaliers
_lat: Latitude
_lon: Longitude
@@ -595,7 +599,10 @@ saved_export:
Export is deleted: L'export est supprimé
Saved export is saved!: L'export est enregistré
Created on %date%: Créé le %date%
update_title_and_description: Modifier le titre et la description
update_filters_aggregators_and_execute: Modifier les filtres et regroupements et télécharger
execute: Télécharger
Update existing: Mettre à jour le rapport enregistré existant
absence:
# single letter for absence
@@ -609,3 +616,32 @@ absence:
You are listed as absent, as of: Votre absence est indiquée à partir du
No absence listed: Aucune absence indiquée.
Is absent: Absent?
admin:
users:
export_list_csv: Liste des utilisateurs (format CSV)
export_permissions_csv: Association utilisateurs - groupes de permissions - centre (format CSV)
export:
id: Identifiant
username: Nom d'utilisateur
email: Courriel
enabled: Activé
civility_id: Identifiant civilité
civility_abbreviation: Abbréviation civilité
civility_name: Civilité
label: Label
mainCenter_id: Identifiant centre principal
mainCenter_name: Centre principal
mainScope_id: Identifiant service principal
mainScope_name: Service principal
userJob_id: Identifiant métier
userJob_name: Métier
currentLocation_id: Identifiant localisation actuelle
currentLocation_name: Localisation actuelle
mainLocation_id: Identifiant localisation principale
mainLocation_name: Localisation principale
absenceStart: Absent à partir du
center_id: Identifiant du centre
center_name: Centre
permissionsGroup_id: Identifiant du groupe de permissions
permissionsGroup_name: Groupe de permissions