Merge remote-tracking branch 'origin/master' into issue120_filter_social_actions

This commit is contained in:
Julien Fastré 2023-07-13 21:14:31 +02:00
commit f2673d6c83
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
85 changed files with 3318 additions and 974 deletions

View File

@ -1,5 +0,0 @@
kind: DX
body: '[FilterOrderHelper] add entity choice and singleCheckbox'
time: 2023-06-23T12:24:08.133491895+02:00
custom:
Issue: ""

View File

@ -1,5 +0,0 @@
kind: Feature
body: '[activity list] add filtering for activities list'
time: 2023-06-23T12:25:30.49643551+02:00
custom:
Issue: ""

View File

@ -1,6 +0,0 @@
kind: Feature
body: '[activity list] in person context, show also the activities from the accompanying
periods where the person participates'
time: 2023-06-23T12:27:02.159041095+02:00
custom:
Issue: ""

View File

@ -1,5 +0,0 @@
kind: Feature
body: '[activity list] add pagination to the list of activities'
time: 2023-06-23T12:44:38.879098862+02:00
custom:
Issue: ""

View File

@ -0,0 +1,5 @@
kind: Feature
body: '[export] Add a list for people with their associated course'
time: 2023-07-07T12:36:09.596469063+02:00
custom:
Issue: "125"

View File

@ -0,0 +1,6 @@
kind: Feature
body: '[export] Add ordering by person''s lastname or course opening date in list
which concerns accompanying course or peoples'
time: 2023-07-07T12:41:32.112725962+02:00
custom:
Issue: ""

View File

@ -0,0 +1,5 @@
kind: Feature
body: '[Export] allow to group activities by localisation'
time: 2023-07-11T15:00:55.770070399+02:00
custom:
Issue: "128"

View File

@ -0,0 +1,5 @@
kind: Feature
body: '[export] Add a filter "filter course having an activity between two dates"'
time: 2023-07-11T15:59:29.065329834+02:00
custom:
Issue: "129"

View File

@ -1,6 +0,0 @@
kind: Fixed
body: '[export] Rename label for CurrentActionFilter (on accompanying period work)
to make precision between "ouvert" and "sans date de fin"'
time: 2023-06-28T17:00:55.206937751+02:00
custom:
Issue: ""

View File

@ -1,6 +0,0 @@
kind: Fixed
body: Force the db to have either a person_location or a address_location, and avoid
to have both also internally in the entity
time: 2023-06-29T12:44:12.019663991+02:00
custom:
Issue: ""

View File

@ -1,5 +0,0 @@
kind: Fixed
body: '[export] set rolling date on person age aggregator'
time: 2023-06-29T23:15:03.20841309+02:00
custom:
Issue: ""

View File

@ -1,5 +0,0 @@
kind: Fixed
body: '[export] fix list when a person locating a course is without address'
time: 2023-06-30T17:11:19.454081914+02:00
custom:
Issue: ""

View File

@ -1,5 +0,0 @@
kind: Fixed
body: '[export] remove unused condition on course about duration participation'
time: 2023-06-30T17:11:53.076615549+02:00
custom:
Issue: ""

View File

@ -0,0 +1,5 @@
kind: Fixed
body: reimplement the visualization of all calculator results (specific to AMLI)
time: 2023-07-12T09:05:14.416268226+02:00
custom:
Issue: ""

View File

@ -0,0 +1,6 @@
kind: Fixed
body: |
Correct bug in thirdparty API search query: simplify address joins clause for child and parent kind
time: 2023-07-13T10:26:40.503796155+02:00
custom:
Issue: "126"

36
.changes/v2.4.0.md Normal file
View File

@ -0,0 +1,36 @@
## v2.4.0 - 2023-07-07
### Feature
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] on "filter by user working" on accompanying period, add two dates to filters intervention within a period
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] Add an aggregator by user's job working on a course
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add an aggregator by user's scope working on a course
* [export] on aggregator "user working on a course"
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add a center aggregator for Person
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add a filter on "job working on a course"
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] Add a filter on "scope working on a course"
* ([#121](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/121)) Create a role "See Confidential Periods", separated from the "Reassign courses" role
* ([#124](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/124)) Sync user absence / presence through microsoft outlook / graph api.
### Fixed
* ([#116](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/116)) On the accompanying course page, open the action on view mode if the user does not have right to update them (i.e. if the accompanying period is closed)
* [export] Rename label for CurrentActionFilter (on accompanying period work) to make precision between "ouvert" and "sans date de fin"
* Force the db to have either a person_location or a address_location, and avoid to have both also internally in the entity
* [export] set rolling date on person age aggregator
* [export] fix list when a person locating a course is without address
* [export] remove unused condition on course about duration participation
* Command to subscribe on MS Graph users calendars: improve the loop to be more efficient
### DX
* Rolling Date: can receive a null parameter
### Traduction francophone des principaux changements
- sur le "filtre par intervenant", ajoute deux dates pour limiter la période d'intervention;
- ajout d'un regroupement par métier des intervenants sur un parcours;
- ajout d'un regroupement par service des intervenants sur un parcours;
- ajout d'un regroupement par utilisateur intervenant sur un parcours
- ajout d'un regroupement "par centre de l'usager";
- ajout d'un filtre "par métier intervenant sur un parcours";
- ajout d'un filtre "par service intervenant sur un parcours";
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);
- synchronisation de l'absence des utilisateurs par microsoft graph api

View File

@ -30,6 +30,8 @@ kinds:
auto: patch
- label: DX
auto: patch
- label: UX
auto: patch
newlines:
afterChangelogHeader: 1
beforeChangelogVersion: 1

View File

@ -6,6 +6,42 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.4.0 - 2023-07-07
### Feature
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] on "filter by user working" on accompanying period, add two dates to filters intervention within a period
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] Add an aggregator by user's job working on a course
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add an aggregator by user's scope working on a course
* [export] on aggregator "user working on a course"
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add a center aggregator for Person
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add a filter on "job working on a course"
* ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] Add a filter on "scope working on a course"
* ([#121](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/121)) Create a role "See Confidential Periods", separated from the "Reassign courses" role
* ([#124](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/124)) Sync user absence / presence through microsoft outlook / graph api.
### Fixed
* ([#116](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/116)) On the accompanying course page, open the action on view mode if the user does not have right to update them (i.e. if the accompanying period is closed)
* [export] Rename label for CurrentActionFilter (on accompanying period work) to make precision between "ouvert" and "sans date de fin"
* Force the db to have either a person_location or a address_location, and avoid to have both also internally in the entity
* [export] set rolling date on person age aggregator
* [export] fix list when a person locating a course is without address
* [export] remove unused condition on course about duration participation
* Command to subscribe on MS Graph users calendars: improve the loop to be more efficient
### DX
* Rolling Date: can receive a null parameter
### Traduction francophone des principaux changements
- sur le "filtre par intervenant", ajoute deux dates pour limiter la période d'intervention;
- ajout d'un regroupement par métier des intervenants sur un parcours;
- ajout d'un regroupement par service des intervenants sur un parcours;
- ajout d'un regroupement par utilisateur intervenant sur un parcours
- ajout d'un regroupement "par centre de l'usager";
- ajout d'un filtre "par métier intervenant sur un parcours";
- ajout d'un filtre "par service intervenant sur un parcours";
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);
- synchronisation de l'absence des utilisateurs par microsoft graph api
## v2.3.0 - 2023-06-27
### Feature
* ([#110](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/110)) Edit saved exports options: the saved exports options (forms, filters, aggregators) are now editable.

View File

@ -18,6 +18,7 @@ These are alias conventions :
| | SocialIssue::class | acp.socialIssues | acpsocialissue |
| | User::class | acp.user | acpuser |
| | AccompanyingPeriopStepHistory::class | acp.stepHistories | acpstephistories |
| | AccompanyingPeriodInfo::class | not existing (using custom WITH clause) | acpinfo |
| AccompanyingPeriodWork::class | | | acpw |
| | AccompanyingPeriodWorkEvaluation::class | acpw.accompanyingPeriodWorkEvaluations | workeval |
| | User::class | acpw.referrers | acpwuser |
@ -28,6 +29,8 @@ These are alias conventions :
| | Person::class | acppart.person | partperson |
| AccompanyingPeriodWorkEvaluation::class | | | workeval |
| | Evaluation::class | workeval.evaluation | eval |
| AccompanyingPeriodInfo::class | | | acpinfo |
| | User::class | acpinfo.user | acpinfo_user |
| Goal::class | | | goal |
| | Result::class | goal.results | goalresult |
| Person::class | | | person |

View File

@ -259,8 +259,8 @@ final class ActivityController extends AbstractController
$filterArgs = [
'my_activities' => $filter->getSingleCheckboxData('my_activities'),
'types' => $filter->getEntityChoiceData('activity_types'),
'jobs' => $filter->getEntityChoiceData('jobs'),
'types' => $filter->hasEntityChoice('activity_types') ? $filter->getEntityChoiceData('activity_types') : [],
'jobs' => $filter->hasEntityChoice('jobs') ? $filter->getEntityChoiceData('jobs') : [],
'before' => $filter->getDateRangeData('activity_date')['to'],
'after' => $filter->getDateRangeData('activity_date')['from'],
];
@ -327,7 +327,10 @@ final class ActivityController extends AbstractController
$filterBuilder
->addDateRange('activity_date', 'activity.date')
->addSingleCheckbox('my_activities', 'activity_filter.My activities')
->addSingleCheckbox('my_activities', 'activity_filter.My activities');
if (1 < count($types)) {
$filterBuilder
->addEntityChoice('activity_types', 'activity_filter.Types', \Chill\ActivityBundle\Entity\ActivityType::class, $types, [
'choice_label' => function (\Chill\ActivityBundle\Entity\ActivityType $activityType) {
$text = match ($activityType->hasCategory()) {
@ -337,11 +340,15 @@ final class ActivityController extends AbstractController
return $text . $this->translatableStringHelper->localize($activityType->getName());
}
])
]);
}
if (1 < count($jobs)) {
$filterBuilder
->addEntityChoice('jobs', 'activity_filter.Jobs', UserJob::class, $jobs, [
'choice_label' => fn (UserJob $u) => $this->translatableStringHelper->localize($u->getLabel())
])
;
]);
}
return $filterBuilder->build();
}

View File

@ -0,0 +1,80 @@
<?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\ActivityBundle\Export\Aggregator;
use Chill\ActivityBundle\Export\Declarations;
use Chill\ActivityBundle\Repository\ActivityTypeRepositoryInterface;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\LocationRepository;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Closure;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use function in_array;
final readonly class ActivityLocationAggregator implements AggregatorInterface
{
public const KEY = 'activity_location_aggregator';
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('actloc', $qb->getAllAliases(), true)) {
$qb->leftJoin('activity.location', 'actloc');
}
$qb->addSelect(sprintf('actloc.name AS %s', self::KEY));
$qb->addGroupBy(self::KEY);
}
public function applyOn(): string
{
return Declarations::ACTIVITY;
}
public function buildForm(FormBuilderInterface $builder)
{
// no form required for this aggregator
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): Closure
{
return function ($value): string {
if ('_header' === $value) {
return 'export.aggregator.activity.by_location.Activity Location';
}
if (null === $value || '' === $value) {
return '';
}
return $value;
};
}
public function getQueryKeys($data): array
{
return [self::KEY];
}
public function getTitle()
{
return 'export.aggregator.activity.by_location.Title';
}
}

View File

@ -0,0 +1,90 @@
<?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\ActivityBundle\Export\Filter\ACPFilters;
use Chill\ActivityBundle\Entity\Activity;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class PeriodHavingActivityBetweenDatesFilter implements FilterInterface
{
public function __construct(
private RollingDateConverterInterface $rollingDateConverter,
) {
}
public function getTitle()
{
return 'export.filter.activity.course_having_activity_between_date.Title';
}
public function buildForm(FormBuilderInterface $builder)
{
$builder
->add('start_date', PickRollingDateType::class, [
'label' => 'export.filter.activity.course_having_activity_between_date.Receiving an activity after'
])
->add('end_date', PickRollingDateType::class, [
'label' => 'export.filter.activity.course_having_activity_between_date.Receiving an activity before'
]);
}
public function getFormDefaultData(): array
{
return [
'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'end_date' => new RollingDate(RollingDate::T_TODAY)
];
}
public function describeAction($data, $format = 'string')
{
return [
'export.filter.activity.course_having_activity_between_date.Only course having an activity between from and to',
[
'from' => $this->rollingDateConverter->convert($data['start_date']),
'to' => $this->rollingDateConverter->convert($data['end_date']),
]
];
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$alias = 'act_period_having_act_betw_date_alias';
$from = 'act_period_having_act_betw_date_start';
$to = 'act_period_having_act_betw_date_end';
$qb->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM ' . Activity::class . " {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = acp"
)
);
$qb
->setParameter($from, $this->rollingDateConverter->convert($data['start_date']))
->setParameter($to, $this->rollingDateConverter->convert($data['end_date']));
}
public function applyOn()
{
return \Chill\PersonBundle\Export\Declarations::ACP_TYPE;
}
}

View File

@ -161,6 +161,7 @@ class TimelineActivityProvider implements TimelineProviderInterface
// loop on reachable scopes
foreach ($reachableScopes as $scope) {
/** @phpstan-ignore-next-line */
if (in_array($scope->getId(), $scopes_ids, true)) {
continue;
}

View File

@ -135,6 +135,10 @@ services:
tags:
- { name: chill.export_filter, alias: 'accompanyingcourse_has_no_activity_filter' }
Chill\ActivityBundle\Export\Filter\ACPFilters\PeriodHavingActivityBetweenDatesFilter:
tags:
- { name: chill.export_filter, alias: 'period_having_activity_betw_dates_filter' }
## Aggregators
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\ActivityReasonAggregator:
tags:
@ -144,6 +148,10 @@ services:
tags:
- { name: chill.export_aggregator, alias: activity_common_type_aggregator }
Chill\ActivityBundle\Export\Aggregator\ActivityLocationAggregator:
tags:
- { name: chill.export_aggregator, alias: activity_common_location_aggregator }
chill.activity.export.user_aggregator:
class: Chill\ActivityBundle\Export\Aggregator\ActivityUserAggregator
tags:

View File

@ -0,0 +1,5 @@
export:
filter:
activity:
course_having_activity_between_date:
Only course having an activity between from and to: Seulement les parcours ayant reçu au moins un échange entre le {from, date, short} et le {to, date, short}

View File

@ -96,9 +96,6 @@ activity_filter:
My activities: Mes échanges (où j'interviens)
Types: Par type d'échange
Jobs: Par métier impliqué
By: Filtrer par
Search: Chercher dans la liste
By date: Filtrer par date
#timeline
'%user% has done an %activity_type%': '%user% a effectué un échange de type "%activity_type%"'
@ -376,6 +373,12 @@ export:
by_usersscope:
Filter by users scope: Filtrer les échanges par services d'au moins un utilisateur participant
'Filtered activity by users scope: only %scopes%': 'Filtré par service d''au moins un utilisateur participant: seulement %scopes%'
course_having_activity_between_date:
Title: Filtre les parcours ayant reçu un échange entre deux dates
Receiving an activity after: Ayant reçu un échange après le
Receiving an activity before: Ayant reçu un échange avant le
aggregator:
activity:
by_sent_received:
@ -383,6 +386,9 @@ export:
is sent: envoyé
is received: reçu
Group activity by sentreceived: Grouper les échanges par envoyé / reçu
by_location:
Activity Location: Localisation de l'échange
Title: Grouper les échanges par localisation de l'échange
generic_doc:
filter:

View File

@ -1,11 +1,12 @@
{% macro table_elements(elements, family) %}
{% macro table_elements(elements, type) %}
<table class="table table-bordered border-dark budget-table">
<thead>
<tr>
<th class="{{ family }} el-type">{{ 'Budget element type'|trans }}</th>
<th class="{{ family }}">{{ 'Amount'|trans }}</th>
<th class="{{ family }}">{{ 'Validity period'|trans }}</th>
<th class="{{ family }}">&nbsp;</th>
<th class="{{ type }} el-type">{{ 'Budget element type'|trans }}</th>
<th class="{{ type }}">{{ 'Amount'|trans }}</th>
<th class="{{ type }}">{{ 'Validity period'|trans }}</th>
<th class="{{ type }}">&nbsp;</th>
</tr>
</thead>
<tbody>
@ -38,17 +39,17 @@
<ul class="record_actions">
{% if is_granted('CHILL_BUDGET_ELEMENT_SEE', f) %}
<li>
<a href="{{ path('chill_budget_' ~ family ~ '_view', { 'id': f.id } ) }}" class="btn btn-sm btn-show"></a>
<a href="{{ path('chill_budget_' ~ type ~ '_view', { 'id': f.id } ) }}" class="btn btn-sm btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_BUDGET_ELEMENT_UPDATE', f) %}
<li>
<a href="{{ path('chill_budget_' ~ family ~'_edit', { 'id': f.id } ) }}" class="btn btn-sm btn-edit"></a>
<a href="{{ path('chill_budget_' ~ type ~'_edit', { 'id': f.id } ) }}" class="btn btn-sm btn-edit"></a>
</li>
{% endif %}
{% if is_granted('CHILL_BUDGET_ELEMENT_DELETE', f) %}
<li>
<a href="{{ path('chill_budget_' ~ family ~ '_delete', { 'id': f.id } ) }}" class="btn btn-sm btn-delete"></a>
<a href="{{ path('chill_budget_' ~ type ~ '_delete', { 'id': f.id } ) }}" class="btn btn-sm btn-delete"></a>
</li>
{% endif %}
</ul>
@ -69,7 +70,7 @@
</table>
{% endmacro %}
{% macro table_results(actualCharges, actualResources) %}
{% macro table_results(actualCharges, actualResources, results) %}
{% set totalCharges = 0 %}
{% for c in actualCharges %}
@ -97,6 +98,20 @@
{{ result|format_currency('EUR') }}
</td>
</tr>
{% for result in results %}
<tr>
<td>{{ result.label }}</td>
<td>
{% if result.type == 'currency' %}
{{ result.result|format_currency('EUR') }}
{% elseif result.type == 'percentage' %}
{{ result.result|round(2, 'ceil') ~ '%' }}
{% else %}
{{ result.result|round(2, 'common') }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}

View File

@ -25,7 +25,7 @@
<div class="mt-5">
<h3 class="subtitle">{{ 'Budget calculator'|trans }}</h3>
{{ table_results(charges, resources) }}
{{ table_results(charges, resources, results) }}
</div>
{% if is_granted('CHILL_BUDGET_ELEMENT_CREATE', person) %}

View File

@ -18,9 +18,12 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\Exception\UserAbsenceSyncException;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\EventsOnUserSubscriptionCreator;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSGraphUserRepository;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceSync;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@ -30,32 +33,17 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class MapAndSubscribeUserCalendarCommand extends Command
final class MapAndSubscribeUserCalendarCommand extends Command
{
private EntityManagerInterface $em;
private EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator;
private LoggerInterface $logger;
private MapCalendarToUser $mapCalendarToUser;
private MSGraphUserRepository $userRepository;
public function __construct(
EntityManagerInterface $em,
EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator,
LoggerInterface $logger,
MapCalendarToUser $mapCalendarToUser,
MSGraphUserRepository $userRepository
private readonly EntityManagerInterface $em,
private readonly EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator,
private readonly LoggerInterface $logger,
private readonly MapCalendarToUser $mapCalendarToUser,
private readonly UserRepositoryInterface $userRepository,
private readonly MSUserAbsenceSync $userAbsenceSync,
) {
parent::__construct('chill:calendar:msgraph-user-map-subscribe');
$this->em = $em;
$this->eventsOnUserSubscriptionCreator = $eventsOnUserSubscriptionCreator;
$this->logger = $logger;
$this->mapCalendarToUser = $mapCalendarToUser;
$this->userRepository = $userRepository;
}
public function execute(InputInterface $input, OutputInterface $output): int
@ -67,28 +55,50 @@ class MapAndSubscribeUserCalendarCommand extends Command
/** @var DateInterval $interval the interval before the end of the expiration */
$interval = new DateInterval('P1D');
$expiration = (new DateTimeImmutable('now'))->add(new DateInterval($input->getOption('subscription-duration')));
$total = $this->userRepository->countByMostOldSubscriptionOrWithoutSubscriptionOrData($interval);
$users = $this->userRepository->findAllAsArray('fr');
$created = 0;
$renewed = 0;
$this->logger->info(self::class . ' the number of user to get - renew', [
'total' => $total,
$this->logger->info(self::class . ' start user to get - renew', [
'expiration' => $expiration->format(DateTimeImmutable::ATOM),
]);
while ($offset < $total) {
$users = $this->userRepository->findByMostOldSubscriptionOrWithoutSubscriptionOrData(
$interval,
$limit,
$offset
);
foreach ($users as $u) {
++$offset;
foreach ($users as $user) {
if (!$this->mapCalendarToUser->hasUserId($user)) {
$this->mapCalendarToUser->writeMetadata($user);
if (false === $u['enabled']) {
continue;
}
$user = $this->userRepository->find($u['id']);
if (null === $user) {
$this->logger->error("could not find user by id", ['uid' => $u['id']]);
$output->writeln("could not find user by id : " . $u['id']);
continue;
}
if (!$this->mapCalendarToUser->hasUserId($user)) {
$user = $this->mapCalendarToUser->writeMetadata($user);
// if user still does not have userid, continue
if (!$this->mapCalendarToUser->hasUserId($user)) {
$this->logger->warning("user does not have a counterpart on ms api", ['userId' => $user->getId(), 'email' => $user->getEmail()]);
$output->writeln(sprintf("giving up for user with email %s and id %s", $user->getEmail(), $user->getId()));
continue;
}
}
// sync user absence
try {
$this->userAbsenceSync->syncUserAbsence($user);
} catch (UserAbsenceSyncException $e) {
$this->logger->error("could not sync user absence", ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), "message" => $e->getMessage()]);
$output->writeln(sprintf("Could not sync user absence: id: %s and email: %s", $user->getId(), $user->getEmail()));
throw $e;
}
if ($this->mapCalendarToUser->hasUserId($user)) {
// we first try to renew an existing subscription, if any.
// if not, or if it fails, we try to create a new one
if ($this->mapCalendarToUser->hasActiveSubscription($user)) {
@ -130,20 +140,24 @@ class MapAndSubscribeUserCalendarCommand extends Command
]);
}
}
}
++$offset;
if (0 === $offset % $limit) {
$this->em->flush();
$this->em->clear();
}
}
$this->em->flush();
$this->em->clear();
}
$this->logger->warning(self::class . ' process executed', [
'created' => $created,
'renewed' => $renewed,
]);
$output->writeln("users synchronized");
return 0;
}
@ -152,7 +166,7 @@ class MapAndSubscribeUserCalendarCommand extends Command
parent::configure();
$this
->setDescription('MSGraph: collect user metadata and create subscription on events for users')
->setDescription('MSGraph: collect user metadata and create subscription on events for users, and sync the user absence-presence')
->addOption(
'renew-before-end-interval',
'r',

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\CalendarBundle\Exception;
class UserAbsenceSyncException extends \LogicException
{
public function __construct(string $message = "", int $code = 20_230_706, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@ -1,84 +0,0 @@
<?php
/**
* 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.
*/
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\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use DateInterval;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use function strtr;
/**
* Contains classes and methods for fetching users with some calendar metadatas.
*/
class MSGraphUserRepository
{
private const MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH = <<<'SQL'
select
{select}
from users u
where
NOT attributes ?? 'msgraph'
OR NOT attributes->'msgraph' ?? 'subscription_events_expiration'
OR (attributes->'msgraph' ?? 'subscription_events_expiration' AND (attributes->'msgraph'->>'subscription_events_expiration')::int < EXTRACT(EPOCH FROM (NOW() + :interval::interval)))
LIMIT :limit OFFSET :offset
;
SQL;
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function countByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval): int
{
$rsm = new ResultSetMapping();
$rsm->addScalarResult('c', 'c');
$sql = strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, [
'{select}' => 'COUNT(u) AS c',
'LIMIT :limit OFFSET :offset' => '',
]);
return $this->entityManager->createNativeQuery($sql, $rsm)->setParameters([
'interval' => $interval,
])->getSingleScalarResult();
}
/**
* @return array|User[]
*/
public function findByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval, int $limit = 50, int $offset = 0): array
{
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(User::class, 'u');
return $this->entityManager->createNativeQuery(
strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, ['{select}' => $rsm->generateSelectClause()]),
$rsm
)->setParameters([
'interval' => $interval,
'limit' => $limit,
'offset' => $offset,
])->getResult();
}
}

View File

@ -0,0 +1,69 @@
<?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\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\CalendarBundle\Exception\UserAbsenceSyncException;
use Chill\MainBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class MSUserAbsenceReader implements MSUserAbsenceReaderInterface
{
public function __construct(
private HttpClientInterface $machineHttpClient,
private MapCalendarToUser $mapCalendarToUser,
private ClockInterface $clock,
) {
}
/**
* @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft
*/
public function isUserAbsent(User $user): bool|null
{
$id = $this->mapCalendarToUser->getUserId($user);
if (null === $id) {
return null;
}
try {
$automaticRepliesSettings = $this->machineHttpClient
->request('GET', 'users/' . $id . '/mailboxSettings/automaticRepliesSetting')
->toArray(true);
} catch (ClientExceptionInterface|DecodingExceptionInterface|RedirectionExceptionInterface|TransportExceptionInterface $e) {
throw new UserAbsenceSyncException("Error receiving response for mailboxSettings", 0, $e);
} catch (ServerExceptionInterface $e) {
throw new UserAbsenceSyncException("Server error receiving response for mailboxSettings", 0, $e);
}
if (!array_key_exists("status", $automaticRepliesSettings)) {
throw new \LogicException("no key \"status\" on automatic replies settings: " . json_encode($automaticRepliesSettings, JSON_THROW_ON_ERROR));
}
return match ($automaticRepliesSettings['status']) {
'disabled' => false,
'alwaysEnabled' => true,
'scheduled' =>
RemoteEventConverter::convertStringDateWithoutTimezone($automaticRepliesSettings['scheduledStartDateTime']['dateTime']) < $this->clock->now()
&& RemoteEventConverter::convertStringDateWithoutTimezone($automaticRepliesSettings['scheduledEndDateTime']['dateTime']) > $this->clock->now(),
default => throw new UserAbsenceSyncException("this status is not documented by Microsoft")
};
}
}

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\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
interface MSUserAbsenceReaderInterface
{
/**
* @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft
*/
public function isUserAbsent(User $user): bool|null;
}

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\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
readonly class MSUserAbsenceSync
{
public function __construct(
private MSUserAbsenceReaderInterface $absenceReader,
private ClockInterface $clock,
private LoggerInterface $logger,
) {
}
public function syncUserAbsence(User $user): void
{
$absence = $this->absenceReader->isUserAbsent($user);
if (null === $absence) {
return;
}
if ($absence === $user->isAbsent()) {
// nothing to do
return;
}
$this->logger->info("will change user absence", ['userId' => $user->getId()]);
if ($absence) {
$this->logger->debug("make user absent", ['userId' => $user->getId()]);
$user->setAbsenceStart($this->clock->now());
} else {
$this->logger->debug("make user present", ['userId' => $user->getId()]);
$user->setAbsenceStart(null);
}
}
}

View File

@ -23,6 +23,8 @@ use Chill\CalendarBundle\Command\MapAndSubscribeUserCalendarCommand;
use Chill\CalendarBundle\Controller\RemoteCalendarConnectAzureController;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineTokenStorage;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReaderInterface;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceSync;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraphRemoteCalendarConnector;
use Chill\CalendarBundle\RemoteCalendar\Connector\NullRemoteCalendarConnector;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
@ -37,17 +39,13 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface
public function process(ContainerBuilder $container)
{
$config = $container->getParameter('chill_calendar');
$connector = null;
if (!$config['remote_calendars_sync']['enabled']) {
$connector = NullRemoteCalendarConnector::class;
}
if ($config['remote_calendars_sync']['microsoft_graph']['enabled']) {
if (true === $config['remote_calendars_sync']['microsoft_graph']['enabled']) {
$connector = MSGraphRemoteCalendarConnector::class;
$container->setAlias(HttpClientInterface::class . ' $machineHttpClient', MachineHttpClient::class);
} else {
$connector = NullRemoteCalendarConnector::class;
// remove services which cannot be loaded
$container->removeDefinition(MapAndSubscribeUserCalendarCommand::class);
$container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class);
@ -55,16 +53,14 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface
$container->removeDefinition(MachineTokenStorage::class);
$container->removeDefinition(MachineHttpClient::class);
$container->removeDefinition(MSGraphRemoteCalendarConnector::class);
$container->removeDefinition(MSUserAbsenceReaderInterface::class);
$container->removeDefinition(MSUserAbsenceSync::class);
}
if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) {
$container->setAlias(Azure::class, 'knpu.oauth2.provider.azure');
}
if (null === $connector) {
throw new RuntimeException('Could not configure remote calendar');
}
foreach ([
NullRemoteCalendarConnector::class,
MSGraphRemoteCalendarConnector::class, ] as $serviceId) {

View File

@ -0,0 +1,176 @@
<?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\CalendarBundle\Tests\RemoteCalendar\Connector\MSGraph;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReader;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* @internal
* @coversNothing
*/
class MSUserAbsenceReaderTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataTestUserAbsence
*/
public function testUserAbsenceReader(string $mockResponse, bool $expected, string $message): void
{
$user = new User();
$client = new MockHttpClient([new MockResponse($mockResponse)]);
$mapUser = $this->prophesize(MapCalendarToUser::class);
$mapUser->getUserId($user)->willReturn('1234');
$clock = new MockClock(new \DateTimeImmutable('2023-07-07T12:00:00'));
$absenceReader = new MSUserAbsenceReader($client, $mapUser->reveal(), $clock);
self::assertEquals($expected, $absenceReader->isUserAbsent($user), $message);
}
public function testIsUserAbsentWithoutRemoteId(): void
{
$user = new User();
$client = new MockHttpClient();
$mapUser = $this->prophesize(MapCalendarToUser::class);
$mapUser->getUserId($user)->willReturn(null);
$clock = new MockClock(new \DateTimeImmutable('2023-07-07T12:00:00'));
$absenceReader = new MSUserAbsenceReader($client, $mapUser->reveal(), $clock);
self::assertNull($absenceReader->isUserAbsent($user), "when no user found, absence should be null");
}
public function provideDataTestUserAbsence(): iterable
{
// contains data that was retrieved from microsoft graph api on 2023-07-06
yield [
<<<'JSON'
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting",
"status": "disabled",
"externalAudience": "none",
"internalReplyMessage": "Je suis en congé.",
"externalReplyMessage": "",
"scheduledStartDateTime": {
"dateTime": "2023-07-06T12:00:00.0000000",
"timeZone": "UTC"
},
"scheduledEndDateTime": {
"dateTime": "2023-07-07T12:00:00.0000000",
"timeZone": "UTC"
}
}
JSON,
false,
"User is present"
];
yield [
<<<'JSON'
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting",
"status": "scheduled",
"externalAudience": "none",
"internalReplyMessage": "Je suis en congé.",
"externalReplyMessage": "",
"scheduledStartDateTime": {
"dateTime": "2023-07-06T11:00:00.0000000",
"timeZone": "UTC"
},
"scheduledEndDateTime": {
"dateTime": "2023-07-21T11:00:00.0000000",
"timeZone": "UTC"
}
}
JSON,
true,
'User is absent with absence scheduled, we are within this period'
];
yield [
<<<'JSON'
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting",
"status": "scheduled",
"externalAudience": "none",
"internalReplyMessage": "Je suis en congé.",
"externalReplyMessage": "",
"scheduledStartDateTime": {
"dateTime": "2023-07-08T11:00:00.0000000",
"timeZone": "UTC"
},
"scheduledEndDateTime": {
"dateTime": "2023-07-21T11:00:00.0000000",
"timeZone": "UTC"
}
}
JSON,
false,
'User is present: absence is scheduled for later'
];
yield [
<<<'JSON'
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting",
"status": "scheduled",
"externalAudience": "none",
"internalReplyMessage": "Je suis en congé.",
"externalReplyMessage": "",
"scheduledStartDateTime": {
"dateTime": "2023-07-05T11:00:00.0000000",
"timeZone": "UTC"
},
"scheduledEndDateTime": {
"dateTime": "2023-07-06T11:00:00.0000000",
"timeZone": "UTC"
}
}
JSON,
false,
'User is present: absence is past'
];
yield [
<<<'JSON'
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting",
"status": "alwaysEnabled",
"externalAudience": "none",
"internalReplyMessage": "Je suis en congé.",
"externalReplyMessage": "",
"scheduledStartDateTime": {
"dateTime": "2023-07-06T12:00:00.0000000",
"timeZone": "UTC"
},
"scheduledEndDateTime": {
"dateTime": "2023-07-07T12:00:00.0000000",
"timeZone": "UTC"
}
}
JSON,
true,
"User is absent: absence is always enabled"
];
}
}

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 Chill\CalendarBundle\Tests\RemoteCalendar\Connector\MSGraph;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReader;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReaderInterface;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceSync;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
/**
* @internal
* @coversNothing
*/
class MSUserAbsenceSyncTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataTestSyncUserAbsence
*/
public function testSyncUserAbsence(User $user, ?bool $absenceFromMicrosoft, bool $expectedAbsence, ?\DateTimeImmutable $expectedAbsenceStart, string $message): void
{
$userAbsenceReader = $this->prophesize(MSUserAbsenceReaderInterface::class);
$userAbsenceReader->isUserAbsent($user)->willReturn($absenceFromMicrosoft);
$clock = new MockClock(new \DateTimeImmutable('2023-07-01T12:00:00'));
$syncer = new MSUserAbsenceSync($userAbsenceReader->reveal(), $clock, new NullLogger());
$syncer->syncUserAbsence($user);
self::assertEquals($expectedAbsence, $user->isAbsent(), $message);
self::assertEquals($expectedAbsenceStart, $user->getAbsenceStart(), $message);
}
public function provideDataTestSyncUserAbsence(): iterable
{
yield [new User(), false, false, null, "user present remains present"];
yield [new User(), true, true, new \DateTimeImmutable('2023-07-01T12:00:00'), "user present becomes absent"];
$user = new User();
$user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00"));
yield [$user, true, true, $abs, "user absent remains absent"];
$user = new User();
$user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00"));
yield [$user, false, false, null, "user absent becomes present"];
yield [new User(), null, false, null, "user not syncable: presence do not change"];
$user = new User();
$user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00"));
yield [$user, null, true, $abs, "user not syncable: absence do not change"];
}
}

View File

@ -16,6 +16,8 @@ use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Entity\Participation;
use Chill\EventBundle\Form\ParticipationType;
use Chill\EventBundle\Security\Authorization\ParticipationVoter;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
use LogicException;
use Psr\Log\LoggerInterface;
use RuntimeException;
@ -509,7 +511,7 @@ class ParticipationController extends AbstractController
/**
* @return \Symfony\Component\Form\FormInterface
*/
protected function createEditFormMultiple(ArrayIterator $participations, Event $event)
protected function createEditFormMultiple(Collection $participations, Event $event)
{
$form = $this->createForm(
\Symfony\Component\Form\Extension\Core\Type\FormType::class,
@ -638,6 +640,7 @@ class ParticipationController extends AbstractController
$ignoredParticipations = $newParticipations = [];
foreach ($participations as $participation) {
/** @var Participation $participation */
// check for authorization
$this->denyAccessUnlessGranted(
ParticipationVoter::CREATE,

View File

@ -160,11 +160,11 @@ class Event implements HasCenterInterface, HasScopeInterface
}
/**
* @return ArrayIterator|Collection|Traversable
* @return Collection<Participation>
*/
public function getParticipations()
{
return $this->getParticipationsOrdered();
return new ArrayCollection(iterator_to_array($this->getParticipationsOrdered()));
}
/**

View File

@ -97,7 +97,7 @@ 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);

View File

@ -40,7 +40,7 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType
'label' => false,
'required' => false,
'attr' => [
'placeholder' => 'activity_filter.Search',
'placeholder' => 'filter_order.Search',
]
]);
}
@ -48,16 +48,7 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType
$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,
@ -148,6 +139,20 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType
}
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)
{
/** @var FilterOrderHelper $helper */

View File

@ -44,13 +44,5 @@ form {
}
.chill_filter_order {
background: $gray-100; /*
border: 3px dashed $white;
background: repeating-linear-gradient(
-45deg,
$gray-100,
$gray-100 2px,
$white 2px,
$white 6px
); */
background: $gray-100;
}

View File

@ -1,6 +1,13 @@
{{ form_start(form) }}
<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="chill_filter_order container-xxl p-5 py-2 my-3">
<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">
@ -11,6 +18,7 @@
</div>
{% endif %}
</div>
{% if form.dateRanges is defined %}
{% set btnSubmit = 1 %}
{% if form.dateRanges|length > 0 %}
@ -19,7 +27,7 @@
{% 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">{{ 'activity_filter.By date'|trans }}</div>
<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">
@ -33,12 +41,13 @@
{% endfor %}
{% endif %}
{% endif %}
{% 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">{{ 'activity_filter.By'|trans }}</div>
<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) }}
@ -49,6 +58,7 @@
{% endfor %}
{% endif %}
{% endif %}
{% if form.entity_choices is defined %}
{% set btnSubmit = 1 %}
{% if form.entity_choices |length > 0 %}
@ -67,6 +77,7 @@
{% endfor %}
{% endif %}
{% endif %}
{% if form.user_pickers is defined %}
{% set btnSubmit = 1 %}
{% if form.user_pickers.children|length > 0 %}
@ -89,7 +100,7 @@
{% set btnSubmit = 1 %}
{% for name, _o in form.single_checkboxes %}
<div class="row my-2">
<div class="col-sm-4 col-form-label">{{ 'activity_filter.By'|trans }}</div>
<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>
@ -102,9 +113,32 @@
<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

@ -195,10 +195,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

@ -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,92 @@
<?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
*
* @param FilterOrderHelper $filterOrderHelper
* @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", get_class($selected)));
}
$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

@ -20,6 +20,11 @@ use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PropertyAccess\PropertyAccessor;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyAccess\PropertyPath;
use Symfony\Component\PropertyAccess\PropertyPathInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function array_merge;
use function count;
@ -34,16 +39,12 @@ class FilterOrderHelper
private array $dateRanges = [];
private FormFactoryInterface $formFactory;
public const FORM_NAME = 'f';
private array $formOptions = [];
private string $formType = FilterOrderType::class;
private RequestStack $requestStack;
private ?array $searchBoxFields = null;
private ?array $submitted = null;
@ -59,11 +60,9 @@ class FilterOrderHelper
private array $userPickers = [];
public function __construct(
FormFactoryInterface $formFactory,
RequestStack $requestStack
private readonly FormFactoryInterface $formFactory,
private readonly RequestStack $requestStack,
) {
$this->formFactory = $formFactory;
$this->requestStack = $requestStack;
}
public function addSingleCheckbox(string $name, string $label): self
@ -98,14 +97,14 @@ class FilterOrderHelper
public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = [], array $options = []): self
{
$missing = count($choices) - count($trans) - 1;
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,
];
@ -135,21 +134,39 @@ class FilterOrderHelper
return $this->userPickers;
}
public function getUserPickerData(string $name)
/**
* @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];
@ -173,6 +190,11 @@ class FilterOrderHelper
return $this->singleCheckbox;
}
public function hasDateRangeData(string $name): bool
{
return array_key_exists($name, $this->dateRanges);
}
/**
* @return array{to: ?DateTimeImmutable, from: ?DateTimeImmutable}
*/
@ -239,7 +261,6 @@ class FilterOrderHelper
}
return $r;
}
private function getFormData(): array

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
{
@ -44,7 +46,7 @@ class FilterOrderHelperBuilder
public function __construct(
FormFactoryInterface $formFactory,
RequestStack $requestStack
RequestStack $requestStack,
) {
$this->formFactory = $formFactory;
$this->requestStack = $requestStack;
@ -99,7 +101,7 @@ class FilterOrderHelperBuilder
{
$helper = new FilterOrderHelper(
$this->formFactory,
$this->requestStack
$this->requestStack,
);
$helper->setSearchBox($this->searchBoxFields);

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
{
@ -22,7 +24,7 @@ class FilterOrderHelperFactory implements FilterOrderHelperFactoryInterface
public function __construct(
FormFactoryInterface $formFactory,
RequestStack $requestStack
RequestStack $requestStack,
) {
$this->formFactory = $formFactory;
$this->requestStack = $requestStack;

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

@ -24,6 +24,7 @@ class Templating extends AbstractExtension
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly FilterOrderGetActiveFilterHelper $filterOrderGetActiveFilterHelper,
) {
}
@ -68,6 +69,7 @@ class Templating extends AbstractExtension
return $environment->render($template, [
'helper' => $helper,
'active' => $this->filterOrderGetActiveFilterHelper->getActiveFilters($helper),
'form' => $helper->buildForm()->createView(),
'options' => $options,
'otherParameters' => $otherParameters,

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

@ -219,13 +219,13 @@ class AccompanyingPeriodController extends AbstractController
]);
$this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event);
$accompanyingPeriodsRaw = $this->accompanyingPeriodACLAwareRepository
->findByPerson($person, AccompanyingPeriodVoter::SEE);
$accompanyingPeriods = $this->accompanyingPeriodACLAwareRepository
->findByPerson($person, AccompanyingPeriodVoter::SEE, ["openingDate" => "DESC", "id" => "DESC"]);
usort($accompanyingPeriodsRaw, static fn ($a, $b) => $b->getOpeningDate() > $a->getOpeningDate());
//usort($accompanyingPeriodsRaw, static fn ($a, $b) => $b->getOpeningDate() <=> $a->getOpeningDate());
// filter visible or not visible
$accompanyingPeriods = array_filter($accompanyingPeriodsRaw, fn (AccompanyingPeriod $ap) => $this->isGranted(AccompanyingPeriodVoter::SEE, $ap));
//$accompanyingPeriods = array_filter($accompanyingPeriodsRaw, fn (AccompanyingPeriod $ap) => $this->isGranted(AccompanyingPeriodVoter::SEE, $ap));
return $this->render('@ChillPerson/AccompanyingPeriod/list.html.twig', [
'accompanying_periods' => $accompanyingPeriods,

View File

@ -78,6 +78,7 @@ class AccompanyingPeriodRegulationListController
$form['jobs']->getData(),
$form['services']->getData(),
$form['locations']->getData(),
['openingDate' => 'DESC', 'id' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);

View File

@ -20,6 +20,7 @@ use Chill\MainBundle\Repository\UserRepository;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepositoryInterface;
use Chill\PersonBundle\Repository\AccompanyingPeriodRepository;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\CallbackTransformer;
@ -30,6 +31,7 @@ use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
@ -85,8 +87,8 @@ class ReassignAccompanyingPeriodController extends AbstractController
*/
public function listAction(Request $request): Response
{
if (!$this->security->isGranted('ROLE_USER') || !$this->security->getUser() instanceof User) {
throw new AccessDeniedException();
if (!$this->security->isGranted(AccompanyingPeriodVoter::REASSIGN_BULK)) {
throw new AccessDeniedHttpException('no right to reassign bulk');
}
$form = $this->buildFilterForm();
@ -96,7 +98,7 @@ class ReassignAccompanyingPeriodController extends AbstractController
$userFrom = $form['user']->getData();
$postalCodes = $form['postal_code']->getData() instanceof PostalCode ? [$form['postal_code']->getData()] : [];
$total = $this->accompanyingPeriodACLAwareRepository->countByUserOpenedAccompanyingPeriod($userFrom);
$total = $this->accompanyingPeriodACLAwareRepository->countByUserAndPostalCodesOpenedAccompanyingPeriod($userFrom, $postalCodes);
$paginator = $this->paginatorFactory->create($total);
$paginator->setItemsPerPage(50);
$periods = $this->accompanyingPeriodACLAwareRepository

View File

@ -983,11 +983,8 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
AccompanyingPeriodVoter::EDIT,
AccompanyingPeriodVoter::DELETE,
],
AccompanyingPeriodVoter::REASSIGN_BULK => [
AccompanyingPeriodVoter::CONFIDENTIAL_CRUD,
],
AccompanyingPeriodVoter::TOGGLE_CONFIDENTIAL => [
AccompanyingPeriodVoter::CONFIDENTIAL_CRUD,
AccompanyingPeriodVoter::TOGGLE_CONFIDENTIAL_ALL => [
AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL,
],
],
]);

View File

@ -0,0 +1,100 @@
<?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\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class JobWorkingOnCourseAggregator implements AggregatorInterface
{
private const COLUMN_NAME = 'user_working_on_course_job_id';
public function __construct(
private UserJobRepositoryInterface $userJobRepository,
private TranslatableStringHelperInterface $translatableStringHelper,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): \Closure
{
return function (int|string|null $jobId) {
if (null === $jobId || '' === $jobId) {
return '';
}
if ('_header' === $jobId) {
return 'export.aggregator.course.by_job_working.job';
}
if (null === $job = $this->userJobRepository->find((int) $jobId)) {
return '';
}
return $this->translatableStringHelper->localize($job->getLabel());
};
}
public function getQueryKeys($data)
{
return [self::COLUMN_NAME];
}
public function getTitle()
{
return 'export.aggregator.course.by_job_working.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('acpinfo', $qb->getAllAliases(), true)) {
$qb->leftJoin(
AccompanyingPeriodInfo::class,
'acpinfo',
Join::WITH,
'acp.id = IDENTITY(acpinfo.accompanyingPeriod)'
);
}
if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) {
$qb->leftJoin('acpinfo.user', 'acpinfo_user');
}
$qb->addSelect('IDENTITY(acpinfo_user.userJob) AS ' . self::COLUMN_NAME);
$qb->addGroupBy(self::COLUMN_NAME);
}
public function applyOn()
{
return Declarations::ACP_TYPE;
}
}

View File

@ -0,0 +1,101 @@
<?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\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class ScopeWorkingOnCourseAggregator implements AggregatorInterface
{
private const COLUMN_NAME = 'user_working_on_course_scope_id';
public function __construct(
private ScopeRepositoryInterface $scopeRepository,
private TranslatableStringHelperInterface $translatableStringHelper,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): \Closure
{
return function (int|string|null $scopeId) {
if (null === $scopeId || '' === $scopeId) {
return '';
}
if ('_header' === $scopeId) {
return 'export.aggregator.course.by_scope_working.scope';
}
if (null === $scope = $this->scopeRepository->find((int) $scopeId)) {
return '';
}
return $this->translatableStringHelper->localize($scope->getName());
};
}
public function getQueryKeys($data)
{
return [self::COLUMN_NAME];
}
public function getTitle()
{
return 'export.aggregator.course.by_scope_working.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('acpinfo', $qb->getAllAliases(), true)) {
$qb->leftJoin(
AccompanyingPeriodInfo::class,
'acpinfo',
Join::WITH,
'acp.id = IDENTITY(acpinfo.accompanyingPeriod)'
);
}
if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) {
$qb->leftJoin('acpinfo.user', 'acpinfo_user');
}
$qb->addSelect('IDENTITY(acpinfo_user.mainScope) AS ' . self::COLUMN_NAME);
$qb->addGroupBy(self::COLUMN_NAME);
}
public function applyOn()
{
return Declarations::ACP_TYPE;
}
}

View File

@ -0,0 +1,100 @@
<?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\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class UserWorkingOnCourseAggregator implements AggregatorInterface
{
private const COLUMN_NAME = 'user_working_on_course_user_id';
public function __construct(
private UserRender $userRender,
private UserRepositoryInterface $userRepository,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): \Closure
{
return function (int|string|null $userId) {
if (null === $userId || '' === $userId) {
return '';
}
if ('_header' === $userId) {
return 'export.aggregator.course.by_user_working.user';
}
if (null === $user = $this->userRepository->find((int) $userId)) {
return '';
}
return $this->userRender->renderString($user, []);
};
}
public function getQueryKeys($data)
{
return [self::COLUMN_NAME];
}
public function getTitle()
{
return 'export.aggregator.course.by_user_working.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('acpinfo', $qb->getAllAliases(), true)) {
$qb->leftJoin(
AccompanyingPeriodInfo::class,
'acpinfo',
Join::WITH,
'acp.id = IDENTITY(acpinfo.accompanyingPeriod)'
);
}
if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) {
$qb->leftJoin('acpinfo.user', 'acpinfo_user');
}
$qb->addSelect('acpinfo_user.id AS ' . self::COLUMN_NAME);
$qb->addGroupBy('acpinfo_user.id');
}
public function applyOn()
{
return Declarations::ACP_TYPE;
}
}

View File

@ -0,0 +1,103 @@
<?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\PersonBundle\Export\Aggregator\PersonAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Export\Declarations;
use Closure;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class CenterAggregator implements AggregatorInterface
{
private const COLUMN_NAME = 'person_center_aggregator';
public function __construct(
private CenterRepositoryInterface $centerRepository,
private RollingDateConverterInterface $rollingDateConverter,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('at_date', PickRollingDateType::class, [
'label' => 'export.aggregator.person.by_center.at_date',
]);
}
public function getFormDefaultData(): array
{
return [
'at_date' => new RollingDate(RollingDate::T_TODAY)
];
}
public function getLabels($key, array $values, $data): Closure
{
return function (int|string|null $value) {
if (null === $value || '' === $value) {
return '';
}
if ('_header' === $value) {
return 'export.aggregator.person.by_center.center';
}
return (string) $this->centerRepository->find((int) $value)?->getName();
};
}
public function getQueryKeys($data)
{
return [self::COLUMN_NAME];
}
public function getTitle()
{
return 'export.aggregator.person.by_center.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$alias = 'pers_center_agg';
$atDate = 'pers_center_agg_at_date';
$qb->leftJoin('person.centerHistory', $alias);
$qb
->andWhere(
$qb->expr()->lte($alias.'.startDate', ':'.$atDate),
)->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.endDate'),
$qb->expr()->gt($alias.'.endDate', ':'.$atDate)
)
);
$qb->setParameter($atDate, $this->rollingDateConverter->convert($data['at_date']));
$qb->addSelect("IDENTITY({$alias}.center) AS " . self::COLUMN_NAME);
$qb->addGroupBy(self::COLUMN_NAME);
}
public function applyOn()
{
return Declarations::PERSON_TYPE;
}
}

View File

@ -29,6 +29,7 @@ use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Chill\PersonBundle\Export\Declarations;
use Chill\PersonBundle\Export\Helper\ListAccompanyingPeriodHelper;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Repository\SocialWork\SocialIssueRepository;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
@ -45,95 +46,13 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function strlen;
class ListAccompanyingPeriod implements ListInterface, GroupedExportInterface
final readonly class ListAccompanyingPeriod implements ListInterface, GroupedExportInterface
{
private const FIELDS = [
'id',
'step',
'stepSince',
'openingDate',
'closingDate',
'referrer',
'referrerSince',
'administrativeLocation',
'locationIsPerson',
'locationIsTemp',
'locationPersonName',
'locationPersonId',
'origin',
'closingMotive',
'confidential',
'emergency',
'intensity',
'job',
'isRequestorPerson',
'isRequestorThirdParty',
'requestorPerson',
'requestorPersonId',
'requestorThirdParty',
'requestorThirdPartyId',
'scopes',
'socialIssues',
'createdAt',
'createdBy',
'updatedAt',
'updatedBy',
];
private ExportAddressHelper $addressHelper;
private DateTimeHelper $dateTimeHelper;
private EntityManagerInterface $entityManager;
private PersonRenderInterface $personRender;
private PersonRepository $personRepository;
private RollingDateConverterInterface $rollingDateConverter;
private SocialIssueRender $socialIssueRender;
private SocialIssueRepository $socialIssueRepository;
private ThirdPartyRender $thirdPartyRender;
private ThirdPartyRepository $thirdPartyRepository;
private TranslatableStringHelperInterface $translatableStringHelper;
private TranslatorInterface $translator;
private UserHelper $userHelper;
public function __construct(
ExportAddressHelper $addressHelper,
DateTimeHelper $dateTimeHelper,
EntityManagerInterface $entityManager,
PersonRenderInterface $personRender,
PersonRepository $personRepository,
ThirdPartyRepository $thirdPartyRepository,
ThirdPartyRender $thirdPartyRender,
SocialIssueRepository $socialIssueRepository,
SocialIssueRender $socialIssueRender,
TranslatableStringHelperInterface $translatableStringHelper,
TranslatorInterface $translator,
RollingDateConverterInterface $rollingDateConverter,
UserHelper $userHelper
private EntityManagerInterface $entityManager,
private RollingDateConverterInterface $rollingDateConverter,
private ListAccompanyingPeriodHelper $listAccompanyingPeriodHelper,
) {
$this->addressHelper = $addressHelper;
$this->dateTimeHelper = $dateTimeHelper;
$this->entityManager = $entityManager;
$this->personRender = $personRender;
$this->personRepository = $personRepository;
$this->socialIssueRender = $socialIssueRender;
$this->socialIssueRepository = $socialIssueRepository;
$this->thirdPartyRender = $thirdPartyRender;
$this->thirdPartyRepository = $thirdPartyRepository;
$this->translatableStringHelper = $translatableStringHelper;
$this->translator = $translator;
$this->rollingDateConverter = $rollingDateConverter;
$this->userHelper = $userHelper;
}
public function buildForm(FormBuilderInterface $builder)
@ -169,141 +88,12 @@ class ListAccompanyingPeriod implements ListInterface, GroupedExportInterface
public function getLabels($key, array $values, $data)
{
if (substr($key, 0, strlen('address_fields')) === 'address_fields') {
return $this->addressHelper->getLabel($key, $values, $data, 'address_fields');
}
switch ($key) {
case 'stepSince':
case 'openingDate':
case 'closingDate':
case 'referrerSince':
case 'createdAt':
case 'updatedAt':
return $this->dateTimeHelper->getLabel('export.list.acp.' . $key);
case 'origin':
case 'closingMotive':
case 'job':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value) {
return '';
}
return $this->translatableStringHelper->localize(json_decode($value, true, 512, JSON_THROW_ON_ERROR));
};
case 'locationPersonName':
case 'requestorPerson':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value || null === $person = $this->personRepository->find($value)) {
return '';
}
return $this->personRender->renderString($person, []);
};
case 'requestorThirdParty':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value || null === $thirdparty = $this->thirdPartyRepository->find($value)) {
return '';
}
return $this->thirdPartyRender->renderString($thirdparty, []);
};
case 'scopes':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value) {
return '';
}
return implode(
'|',
array_map(
fn ($s) => $this->translatableStringHelper->localize($s),
json_decode($value, true, 512, JSON_THROW_ON_ERROR)
)
);
};
case 'socialIssues':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value) {
return '';
}
return implode(
'|',
array_map(
fn ($s) => $this->socialIssueRender->renderString($this->socialIssueRepository->find($s), []),
json_decode($value, true, 512, JSON_THROW_ON_ERROR)
)
);
};
case 'step':
return fn ($value) => match ($value) {
'_header' => 'export.list.acp.step',
null => '',
AccompanyingPeriod::STEP_DRAFT => $this->translator->trans('course.draft'),
AccompanyingPeriod::STEP_CONFIRMED => $this->translator->trans('course.confirmed'),
AccompanyingPeriod::STEP_CLOSED => $this->translator->trans('course.closed'),
AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT => $this->translator->trans('course.inactive_short'),
AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_LONG => $this->translator->trans('course.inactive_long'),
default => $value,
};
case 'intensity':
return fn ($value) => match ($value) {
'_header' => 'export.list.acp.intensity',
null => '',
AccompanyingPeriod::INTENSITY_OCCASIONAL => $this->translator->trans('occasional'),
AccompanyingPeriod::INTENSITY_REGULAR => $this->translator->trans('regular'),
default => $value,
};
default:
return static function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value) {
return '';
}
return $value;
};
}
return $this->listAccompanyingPeriodHelper->getLabels($key, $values, $data);
}
public function getQueryKeys($data)
{
return array_merge(
self::FIELDS,
$this->addressHelper->getKeys(ExportAddressHelper::F_ALL, 'address_fields')
);
return $this->listAccompanyingPeriodHelper->getQueryKeys($data);
}
public function getResult($query, $data)
@ -341,7 +131,12 @@ class ListAccompanyingPeriod implements ListInterface, GroupedExportInterface
->setParameter('list_acp_step', AccompanyingPeriod::STEP_DRAFT)
->setParameter('authorized_centers', $centers);
$this->addSelectClauses($qb, $this->rollingDateConverter->convert($data['calc_date']));
$this->listAccompanyingPeriodHelper->addSelectClauses($qb, $this->rollingDateConverter->convert($data['calc_date']));
$qb
->addOrderBy('acp.openingDate')
->addOrderBy('acp.closingDate')
->addOrderBy('acp.id');
return $qb;
}
@ -357,91 +152,4 @@ class ListAccompanyingPeriod implements ListInterface, GroupedExportInterface
Declarations::ACP_TYPE,
];
}
private function addSelectClauses(QueryBuilder $qb, DateTimeImmutable $calcDate): void
{
// add the regular fields
foreach (['id', 'openingDate', 'closingDate', 'confidential', 'emergency', 'intensity', 'createdAt', 'updatedAt'] as $field) {
$qb->addSelect(sprintf('acp.%s AS %s', $field, $field));
}
// add the field which are simple association
foreach (['origin' => 'label', 'closingMotive' => 'name', 'job' => 'label', 'createdBy' => 'label', 'updatedBy' => 'label', 'administrativeLocation' => 'name'] as $entity => $field) {
$qb
->leftJoin(sprintf('acp.%s', $entity), "{$entity}_t")
->addSelect(sprintf('%s_t.%s AS %s', $entity, $field, $entity));
}
// step at date
$qb
->addSelect('stepHistory.step AS step')
->addSelect('stepHistory.startDate AS stepSince')
->leftJoin('acp.stepHistories', 'stepHistory')
->andWhere(
$qb->expr()->andX(
$qb->expr()->lte('stepHistory.startDate', ':calcDate'),
$qb->expr()->orX($qb->expr()->isNull('stepHistory.endDate'), $qb->expr()->gt('stepHistory.endDate', ':calcDate'))
)
);
// referree at date
$qb
->addSelect('referrer_t.label AS referrer')
->addSelect('userHistory.startDate AS referrerSince')
->leftJoin('acp.userHistories', 'userHistory')
->leftJoin('userHistory.user', 'referrer_t')
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('userHistory'),
$qb->expr()->andX(
$qb->expr()->lte('userHistory.startDate', ':calcDate'),
$qb->expr()->orX($qb->expr()->isNull('userHistory.endDate'), $qb->expr()->gt('userHistory.endDate', ':calcDate'))
)
)
);
// location of the acp
$qb
->addSelect('CASE WHEN locationHistory.personLocation IS NOT NULL THEN 1 ELSE 0 END AS locationIsPerson')
->addSelect('CASE WHEN locationHistory.personLocation IS NOT NULL THEN 0 ELSE 1 END AS locationIsTemp')
->addSelect('IDENTITY(locationHistory.personLocation) AS locationPersonName')
->addSelect('IDENTITY(locationHistory.personLocation) AS locationPersonId')
->leftJoin('acp.locationHistories', 'locationHistory')
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('locationHistory'),
$qb->expr()->andX(
$qb->expr()->lte('locationHistory.startDate', ':calcDate'),
$qb->expr()->orX($qb->expr()->isNull('locationHistory.endDate'), $qb->expr()->gt('locationHistory.endDate', ':calcDate'))
)
)
)
->leftJoin(
PersonHouseholdAddress::class,
'personAddress',
Join::WITH,
'locationHistory.personLocation = personAddress.person AND (personAddress.validFrom <= :calcDate AND (personAddress.validTo IS NULL OR personAddress.validTo > :calcDate))'
)
->leftJoin(Address::class, 'acp_address', Join::WITH, 'COALESCE(IDENTITY(locationHistory.addressLocation), IDENTITY(personAddress.address)) = acp_address.id');
$this->addressHelper->addSelectClauses(ExportAddressHelper::F_ALL, $qb, 'acp_address', 'address_fields');
// requestor
$qb
->addSelect('CASE WHEN acp.requestorPerson IS NULL THEN 1 ELSE 0 END AS isRequestorPerson')
->addSelect('CASE WHEN acp.requestorPerson IS NULL THEN 0 ELSE 1 END AS isRequestorThirdParty')
->addSelect('IDENTITY(acp.requestorPerson) AS requestorPersonId')
->addSelect('IDENTITY(acp.requestorThirdParty) AS requestorThirdPartyId')
->addSelect('IDENTITY(acp.requestorPerson) AS requestorPerson')
->addSelect('IDENTITY(acp.requestorThirdParty) AS requestorThirdParty');
$qb
// scopes
->addSelect('(SELECT AGGREGATE(scope.name) FROM ' . Scope::class . ' scope WHERE scope MEMBER OF acp.scopes) AS scopes')
// social issues
->addSelect('(SELECT AGGREGATE(socialIssue.id) FROM ' . SocialIssue::class . ' socialIssue WHERE socialIssue MEMBER OF acp.socialIssues) AS socialIssues');
// add parameter
$qb->setParameter('calcDate', $calcDate);
}
}

View File

@ -35,7 +35,12 @@ use function count;
use function in_array;
use function strlen;
class ListPersonWithAccompanyingPeriod implements ExportElementValidatedInterface, ListInterface, GroupedExportInterface
/**
* List the persons, having an accompanying period.
*
* Details of the accompanying period are not included
*/
class ListPersonHavingAccompanyingPeriod implements ExportElementValidatedInterface, ListInterface, GroupedExportInterface
{
private ExportAddressHelper $addressHelper;
@ -185,6 +190,11 @@ class ListPersonWithAccompanyingPeriod implements ExportElementValidatedInterfac
$this->listPersonHelper->addSelect($qb, $fields, $data['address_date']);
$qb
->addOrderBy('person.lastName')
->addOrderBy('person.firstName')
->addOrderBy('person.id');
return $qb;
}

View File

@ -0,0 +1,155 @@
<?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\PersonBundle\Export\Export;
use Chill\MainBundle\Export\ExportElementValidatedInterface;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Export\Helper\ExportAddressHelper;
use Chill\MainBundle\Export\ListInterface;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Chill\PersonBundle\Export\Declarations;
use Chill\PersonBundle\Export\Helper\ListAccompanyingPeriodHelper;
use Chill\PersonBundle\Export\Helper\ListPersonHelper;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use DateTimeImmutable;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use function array_key_exists;
use function count;
use function in_array;
use function strlen;
/**
* List the persons having an accompanying period, with the accompanying period details
*
*/
final readonly class ListPersonWithAccompanyingPeriodDetails implements ListInterface, GroupedExportInterface
{
public function __construct(
private ListPersonHelper $listPersonHelper,
private ListAccompanyingPeriodHelper $listAccompanyingPeriodHelper,
private EntityManagerInterface $entityManager,
private RollingDateConverterInterface $rollingDateConverter,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('address_date', PickRollingDateType::class, [
'label' => 'Data valid at this date',
'help' => 'Data regarding center, addresses, and so on will be computed at this date',
]);
}
public function getFormDefaultData(): array
{
return ['address_date' => new RollingDate(RollingDate::T_TODAY)];
}
public function getAllowedFormattersTypes()
{
return [FormatterInterface::TYPE_LIST];
}
public function getDescription()
{
return 'export.list.person_with_acp.Create a list of people having an accompaying periods with details of period, according to various filters.';
}
public function getGroup(): string
{
return 'Exports of persons';
}
public function getLabels($key, array $values, $data)
{
if (in_array($key, $this->listPersonHelper->getAllKeys(), true)) {
return $this->listPersonHelper->getLabels($key, $values, $data);
}
return $this->listAccompanyingPeriodHelper->getLabels($key, $values, $data);
}
public function getQueryKeys($data)
{
return array_merge(
$this->listPersonHelper->getAllKeys(),
$this->listAccompanyingPeriodHelper->getQueryKeys($data),
);
}
public function getResult($query, $data)
{
return $query->getQuery()->getResult(AbstractQuery::HYDRATE_SCALAR);
}
public function getTitle()
{
return 'export.list.person_with_acp.List peoples having an accompanying period with period details';
}
public function getType()
{
return Declarations::PERSON_TYPE;
}
/**
* param array{fields: string[], address_date: DateTimeImmutable} $data.
*/
public function initiateQuery(array $requiredModifiers, array $acl, array $data = [])
{
$centers = array_map(static fn ($el) => $el['center'], $acl);
$qb = $this->entityManager->createQueryBuilder();
$qb->from(Person::class, 'person')
->join('person.accompanyingPeriodParticipations', 'acppart')
->join('acppart.accompanyingPeriod', 'acp')
->andWhere($qb->expr()->neq('acp.step', "'" . AccompanyingPeriod::STEP_DRAFT . "'"))
->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM ' . PersonCenterHistory::class . ' pch WHERE pch.person = person.id AND pch.center IN (:authorized_centers)'
)
)->setParameter('authorized_centers', $centers);
$this->listPersonHelper->addSelect($qb, ListPersonHelper::FIELDS, $this->rollingDateConverter->convert($data['address_date']));
$this->listAccompanyingPeriodHelper->addSelectClauses($qb, $this->rollingDateConverter->convert($data['address_date']));
$qb
->addOrderBy('person.lastName')
->addOrderBy('person.firstName')
->addOrderBy('person.id')
->addOrderBy('acp.id');
return $qb;
}
public function requiredRole(): string
{
return PersonVoter::LISTS;
}
public function supportsModifiers()
{
return [Declarations::PERSON_TYPE, Declarations::PERSON_IMPLIED_IN, Declarations::ACP_TYPE];
}
}

View File

@ -0,0 +1,130 @@
<?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\PersonBundle\Export\Filter\AccompanyingCourseFilters;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* Filter course where a user with the given job is "working" on it
*
* Makes use of AccompanyingPeriodInfo
*/
readonly class JobWorkingOnCourseFilter implements FilterInterface
{
public function __construct(
private UserJobRepositoryInterface $userJobRepository,
private RollingDateConverterInterface $rollingDateConverter,
private TranslatableStringHelperInterface $translatableStringHelper,
) {
}
public function buildForm(FormBuilderInterface $builder): void
{
$jobs = $this->userJobRepository->findAllActive();
usort($jobs, fn (UserJob $a, UserJob $b) => $this->translatableStringHelper->localize($a->getLabel()) <=> $this->translatableStringHelper->localize($b->getLabel()));
$builder
->add('jobs', EntityType::class, [
'class' => UserJob::class,
'choices' => $jobs,
'choice_label' => fn (UserJob $userJob) => $this->translatableStringHelper->localize($userJob->getLabel()),
'multiple' => true,
'expanded' => true,
])
->add('start_date', PickRollingDateType::class, [
'label' => 'export.filter.course.by_job_working.Job working after'
])
->add('end_date', PickRollingDateType::class, [
'label' => 'export.filter.course.by_job_working.Job working before'
])
;
}
public function getFormDefaultData(): array
{
return [
'jobs' => [],
'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'end_date' => new RollingDate(RollingDate::T_TODAY),
];
}
public function getTitle(): string
{
return 'export.filter.course.by_job_working.title';
}
public function describeAction($data, $format = 'string'): array
{
return [
'export.filter.course.by_job_working.Filtered by job working on course: only %jobs%, between %start_date% and %end_date%', [
'%jobs%' => implode(
', ',
array_map(
fn (UserJob $userJob) => $this->translatableStringHelper->localize($userJob->getLabel()),
$data['jobs']
)
),
'%start_date%' => $this->rollingDateConverter->convert($data['start_date'])?->format('d-m-Y'),
'%end_date%' => $this->rollingDateConverter->convert($data['end_date'])?->format('d-m-Y'),
],
];
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data): void
{
$ai_alias = 'jobs_working_on_course_filter_acc_info';
$ai_user_alias = 'jobs_working_on_course_filter_user';
$ai_jobs = 'jobs_working_on_course_filter_jobs';
$start = 'acp_jobs_work_on_start';
$end = 'acp_jobs_work_on_end';
$qb
->andWhere(
$qb->expr()->exists(
"SELECT 1 FROM " . AccompanyingPeriod\AccompanyingPeriodInfo::class . " {$ai_alias} JOIN {$ai_alias}.user {$ai_user_alias} " .
"WHERE IDENTITY({$ai_alias}.accompanyingPeriod) = acp.id
AND {$ai_user_alias}.userJob IN (:{$ai_jobs})
AND {$ai_alias}.infoDate >= :{$start} and {$ai_alias}.infoDate < :{$end}
"
)
)
->setParameter($ai_jobs, $data['jobs'])
->setParameter($start, $this->rollingDateConverter->convert($data['start_date']))
->setParameter($end, $this->rollingDateConverter->convert($data['end_date']))
;
}
public function applyOn(): string
{
return Declarations::ACP_TYPE;
}
}

View File

@ -38,7 +38,7 @@ class OpenBetweenDatesFilter implements FilterInterface
{
$clause = $qb->expr()->andX(
$qb->expr()->gte('acp.openingDate', ':datefrom'),
$qb->expr()->lte('acp.openingDate', ':dateto')
$qb->expr()->lt('acp.openingDate', ':dateto')
);
$qb->andWhere($clause);

View File

@ -0,0 +1,132 @@
<?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\PersonBundle\Export\Filter\AccompanyingCourseFilters;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* Filter course where a user with the given scope is "working" on it
*
* Makes use of AccompanyingPeriodInfo
*/
readonly class ScopeWorkingOnCourseFilter implements FilterInterface
{
public function __construct(
private ScopeRepositoryInterface $scopeRepository,
private RollingDateConverterInterface $rollingDateConverter,
private TranslatableStringHelperInterface $translatableStringHelper,
) {
}
public function buildForm(FormBuilderInterface $builder): void
{
$scopes = $this->scopeRepository->findAllActive();
usort($scopes, fn (Scope $a, Scope $b) => $this->translatableStringHelper->localize($a->getName()) <=> $this->translatableStringHelper->localize($b->getName()));
$builder
->add('scopes', EntityType::class, [
'class' => Scope::class,
'choices' => $scopes,
'choice_label' => fn (Scope $scope) => $this->translatableStringHelper->localize($scope->getName()),
'multiple' => true,
'expanded' => true,
])
->add('start_date', PickRollingDateType::class, [
'label' => 'export.filter.course.by_scope_working.Scope working after'
])
->add('end_date', PickRollingDateType::class, [
'label' => 'export.filter.course.by_scope_working.Scope working before'
])
;
}
public function getFormDefaultData(): array
{
return [
'scopes' => [],
'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'end_date' => new RollingDate(RollingDate::T_TODAY),
];
}
public function getTitle(): string
{
return 'export.filter.course.by_scope_working.title';
}
public function describeAction($data, $format = 'string'): array
{
return [
'export.filter.course.by_scope_working.Filtered by scope working on course: only %scopes%, between %start_date% and %end_date%', [
'%scopes%' => implode(
', ',
array_map(
fn (Scope $scope) => $this->translatableStringHelper->localize($scope->getName()),
$data['scopes']
)
),
'%start_date%' => $this->rollingDateConverter->convert($data['start_date'])?->format('d-m-Y'),
'%end_date%' => $this->rollingDateConverter->convert($data['end_date'])?->format('d-m-Y'),
],
];
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data): void
{
$ai_alias = 'scopes_working_on_course_filter_acc_info';
$ai_user_alias = 'scopes_working_on_course_filter_user';
$ai_scopes = 'scopes_working_on_course_filter_scopes';
$start = 'acp_scopes_work_on_start';
$end = 'acp_scopes_work_on_end';
$qb
->andWhere(
$qb->expr()->exists(
"SELECT 1 FROM " . AccompanyingPeriod\AccompanyingPeriodInfo::class . " {$ai_alias} JOIN {$ai_alias}.user {$ai_user_alias} " .
"WHERE IDENTITY({$ai_alias}.accompanyingPeriod) = acp.id
AND {$ai_user_alias}.mainScope IN (:{$ai_scopes})
AND {$ai_alias}.infoDate >= :{$start} and {$ai_alias}.infoDate < :{$end}
"
)
)
->setParameter($ai_scopes, $data['scopes'])
->setParameter($start, $this->rollingDateConverter->convert($data['start_date']))
->setParameter($end, $this->rollingDateConverter->convert($data['end_date']))
;
}
public function applyOn(): string
{
return Declarations::ACP_TYPE;
}
}

View File

@ -13,7 +13,10 @@ namespace Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Export\Declarations;
@ -27,11 +30,9 @@ use Symfony\Component\Form\FormBuilderInterface;
*/
readonly class UserWorkingOnCourseFilter implements FilterInterface
{
private const AI_ALIAS = 'user_working_on_course_filter_acc_info';
private const AI_USERS = 'user_working_on_course_filter_users';
public function __construct(
private UserRender $userRender,
private RollingDateConverterInterface $rollingDateConverter,
) {
}
@ -40,11 +41,23 @@ readonly class UserWorkingOnCourseFilter implements FilterInterface
$builder
->add('users', PickUserDynamicType::class, [
'multiple' => true,
]);
])
->add('start_date', PickRollingDateType::class, [
'label' => 'export.filter.course.by_user_working.User working after'
])
->add('end_date', PickRollingDateType::class, [
'label' => 'export.filter.course.by_user_working.User working before'
])
;
}
public function getFormDefaultData(): array
{
return [];
return [
'users' => [],
'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'end_date' => new RollingDate(RollingDate::T_TODAY),
];
}
public function getTitle(): string
@ -55,7 +68,7 @@ readonly class UserWorkingOnCourseFilter implements FilterInterface
public function describeAction($data, $format = 'string'): array
{
return [
'export.filter.course.by_user_working.Filtered by user working on course: only %users%', [
'export.filter.course.by_user_working.Filtered by user working on course: only %users%, between %start_date% and %end_date%', [
'%users%' => implode(
', ',
array_map(
@ -63,6 +76,8 @@ readonly class UserWorkingOnCourseFilter implements FilterInterface
$data['users']
)
),
'%start_date%' => $this->rollingDateConverter->convert($data['start_date'])?->format('d-m-Y'),
'%end_date%' => $this->rollingDateConverter->convert($data['end_date'])?->format('d-m-Y'),
],
];
}
@ -74,14 +89,21 @@ readonly class UserWorkingOnCourseFilter implements FilterInterface
public function alterQuery(QueryBuilder $qb, $data): void
{
$ai_alias = 'user_working_on_course_filter_acc_info';
$ai_users = 'user_working_on_course_filter_users';
$start = 'acp_use_work_on_start';
$end = 'acp_use_work_on_end';
$qb
->andWhere(
$qb->expr()->exists(
"SELECT 1 FROM " . AccompanyingPeriod\AccompanyingPeriodInfo::class . " " . self::AI_ALIAS . " " .
"WHERE " . self::AI_ALIAS . ".user IN (:" . self::AI_USERS .") AND IDENTITY(" . self::AI_ALIAS . ".accompanyingPeriod) = acp.id"
"SELECT 1 FROM " . AccompanyingPeriod\AccompanyingPeriodInfo::class . " {$ai_alias} " .
"WHERE {$ai_alias}.user IN (:{$ai_users}) AND IDENTITY({$ai_alias}.accompanyingPeriod) = acp.id AND {$ai_alias}.infoDate >= :{$start} and {$ai_alias}.infoDate < :{$end}"
)
)
->setParameter(self::AI_USERS, $data['users'])
->setParameter($ai_users, $data['users'])
->setParameter($start, $this->rollingDateConverter->convert($data['start_date']))
->setParameter($end, $this->rollingDateConverter->convert($data['end_date']))
;
}

View File

@ -0,0 +1,317 @@
<?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\PersonBundle\Export\Helper;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Export\Helper\DateTimeHelper;
use Chill\MainBundle\Export\Helper\ExportAddressHelper;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Repository\SocialWork\SocialIssueRepository;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use Chill\PersonBundle\Templating\Entity\SocialIssueRender;
use Chill\ThirdPartyBundle\Repository\ThirdPartyRepository;
use Chill\ThirdPartyBundle\Templating\Entity\ThirdPartyRender;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class ListAccompanyingPeriodHelper
{
public const FIELDS = [
'acpId',
'step',
'stepSince',
'openingDate',
'closingDate',
'referrer',
'referrerSince',
'administrativeLocation',
'locationIsPerson',
'locationIsTemp',
'locationPersonName',
'locationPersonId',
'origin',
'closingMotive',
'confidential',
'emergency',
'intensity',
'job',
'isRequestorPerson',
'isRequestorThirdParty',
'requestorPerson',
'requestorPersonId',
'requestorThirdParty',
'requestorThirdPartyId',
'scopes',
'socialIssues',
'acpCreatedAt',
'acpCreatedBy',
'acpUpdatedAt',
'acpUpdatedBy',
];
public function __construct(
private ExportAddressHelper $addressHelper,
private DateTimeHelper $dateTimeHelper,
private PersonRenderInterface $personRender,
private PersonRepository $personRepository,
private ThirdPartyRepository $thirdPartyRepository,
private ThirdPartyRender $thirdPartyRender,
private SocialIssueRepository $socialIssueRepository,
private SocialIssueRender $socialIssueRender,
private TranslatableStringHelperInterface $translatableStringHelper,
private TranslatorInterface $translator,
) {
}
public function getQueryKeys($data)
{
return array_merge(
ListAccompanyingPeriodHelper::FIELDS,
$this->addressHelper->getKeys(ExportAddressHelper::F_ALL, 'acp_address_fields')
);
}
public function getLabels($key, array $values, $data)
{
if (str_starts_with($key, 'acp_address_fields')) {
return $this->addressHelper->getLabel($key, $values, $data, 'acp_address_fields');
}
switch ($key) {
case 'stepSince':
case 'openingDate':
case 'closingDate':
case 'referrerSince':
case 'acpCreatedAt':
case 'acpUpdatedAt':
return $this->dateTimeHelper->getLabel('export.list.acp.' . $key);
case 'origin':
case 'closingMotive':
case 'job':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value) {
return '';
}
return $this->translatableStringHelper->localize(json_decode($value, true, 512, JSON_THROW_ON_ERROR));
};
case 'locationPersonName':
case 'requestorPerson':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value || null === $person = $this->personRepository->find($value)) {
return '';
}
return $this->personRender->renderString($person, []);
};
case 'requestorThirdParty':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value || null === $thirdparty = $this->thirdPartyRepository->find($value)) {
return '';
}
return $this->thirdPartyRender->renderString($thirdparty, []);
};
case 'scopes':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value) {
return '';
}
return implode(
'|',
array_map(
fn ($s) => $this->translatableStringHelper->localize($s),
json_decode($value, true, 512, JSON_THROW_ON_ERROR)
)
);
};
case 'socialIssues':
return function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value) {
return '';
}
return implode(
'|',
array_map(
fn ($s) => $this->socialIssueRender->renderString($this->socialIssueRepository->find($s), []),
json_decode($value, true, 512, JSON_THROW_ON_ERROR)
)
);
};
case 'step':
return fn ($value) => match ($value) {
'_header' => 'export.list.acp.step',
null => '',
AccompanyingPeriod::STEP_DRAFT => $this->translator->trans('course.draft'),
AccompanyingPeriod::STEP_CONFIRMED => $this->translator->trans('course.confirmed'),
AccompanyingPeriod::STEP_CLOSED => $this->translator->trans('course.closed'),
AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_SHORT => $this->translator->trans('course.inactive_short'),
AccompanyingPeriod::STEP_CONFIRMED_INACTIVE_LONG => $this->translator->trans('course.inactive_long'),
default => $value,
};
case 'intensity':
return fn ($value) => match ($value) {
'_header' => 'export.list.acp.intensity',
null => '',
AccompanyingPeriod::INTENSITY_OCCASIONAL => $this->translator->trans('occasional'),
AccompanyingPeriod::INTENSITY_REGULAR => $this->translator->trans('regular'),
default => $value,
};
default:
return static function ($value) use ($key) {
if ('_header' === $value) {
return 'export.list.acp.' . $key;
}
if (null === $value) {
return '';
}
return $value;
};
}
}
public function addSelectClauses(QueryBuilder $qb, \DateTimeImmutable $calcDate): void
{
$qb->addSelect('acp.id AS acpId');
$qb->addSelect('acp.createdAt AS acpCreatedAt');
$qb->addSelect('acp.updatedAt AS acpUpdatedAt');
// add the regular fields
foreach (['openingDate', 'closingDate', 'confidential', 'emergency', 'intensity'] as $field) {
$qb->addSelect(sprintf('acp.%s AS %s', $field, $field));
}
// add the field which are simple association
$qb
->leftJoin('acp.createdBy', "acp_created_by_t")
->addSelect('acp_created_by_t.label AS acpCreatedBy');
$qb
->leftJoin('acp.updatedBy', "acp_updated_by_t")
->addSelect('acp_updated_by_t.label AS acpUpdatedBy');
foreach (['origin' => 'label', 'closingMotive' => 'name', 'job' => 'label', 'administrativeLocation' => 'name'] as $entity => $field) {
$qb
->leftJoin(sprintf('acp.%s', $entity), "{$entity}_t")
->addSelect(sprintf('%s_t.%s AS %s', $entity, $field, $entity));
}
// step at date
$qb
->addSelect('stepHistory.step AS step')
->addSelect('stepHistory.startDate AS stepSince')
->leftJoin('acp.stepHistories', 'stepHistory')
->andWhere(
$qb->expr()->andX(
$qb->expr()->lte('stepHistory.startDate', ':calcDate'),
$qb->expr()->orX($qb->expr()->isNull('stepHistory.endDate'), $qb->expr()->gt('stepHistory.endDate', ':calcDate'))
)
);
// referree at date
$qb
->addSelect('referrer_t.label AS referrer')
->addSelect('userHistory.startDate AS referrerSince')
->leftJoin('acp.userHistories', 'userHistory')
->leftJoin('userHistory.user', 'referrer_t')
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('userHistory'),
$qb->expr()->andX(
$qb->expr()->lte('userHistory.startDate', ':calcDate'),
$qb->expr()->orX($qb->expr()->isNull('userHistory.endDate'), $qb->expr()->gt('userHistory.endDate', ':calcDate'))
)
)
);
// location of the acp
$qb
->addSelect('CASE WHEN locationHistory.personLocation IS NOT NULL THEN 1 ELSE 0 END AS locationIsPerson')
->addSelect('CASE WHEN locationHistory.personLocation IS NOT NULL THEN 0 ELSE 1 END AS locationIsTemp')
->addSelect('IDENTITY(locationHistory.personLocation) AS locationPersonName')
->addSelect('IDENTITY(locationHistory.personLocation) AS locationPersonId')
->leftJoin('acp.locationHistories', 'locationHistory')
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('locationHistory'),
$qb->expr()->andX(
$qb->expr()->lte('locationHistory.startDate', ':calcDate'),
$qb->expr()->orX($qb->expr()->isNull('locationHistory.endDate'), $qb->expr()->gt('locationHistory.endDate', ':calcDate'))
)
)
)
->leftJoin(
PersonHouseholdAddress::class,
'acpPersonAddress',
Join::WITH,
'locationHistory.personLocation = acpPersonAddress.person AND (acpPersonAddress.validFrom <= :calcDate AND (acpPersonAddress.validTo IS NULL OR acpPersonAddress.validTo > :calcDate))'
)
->leftJoin(Address::class, 'acp_address', Join::WITH, 'COALESCE(IDENTITY(locationHistory.addressLocation), IDENTITY(acpPersonAddress.address)) = acp_address.id');
$this->addressHelper->addSelectClauses(ExportAddressHelper::F_ALL, $qb, 'acp_address', 'acp_address_fields');
// requestor
$qb
->addSelect('CASE WHEN acp.requestorPerson IS NULL THEN 1 ELSE 0 END AS isRequestorPerson')
->addSelect('CASE WHEN acp.requestorPerson IS NULL THEN 0 ELSE 1 END AS isRequestorThirdParty')
->addSelect('IDENTITY(acp.requestorPerson) AS requestorPersonId')
->addSelect('IDENTITY(acp.requestorThirdParty) AS requestorThirdPartyId')
->addSelect('IDENTITY(acp.requestorPerson) AS requestorPerson')
->addSelect('IDENTITY(acp.requestorThirdParty) AS requestorThirdParty');
$qb
// scopes
->addSelect('(SELECT AGGREGATE(scope.name) FROM ' . Scope::class . ' scope WHERE scope MEMBER OF acp.scopes) AS scopes')
// social issues
->addSelect('(SELECT AGGREGATE(socialIssue.id) FROM ' . SocialIssue::class . ' socialIssue WHERE socialIssue MEMBER OF acp.socialIssues) AS socialIssues');
// add parameter
$qb->setParameter('calcDate', $calcDate);
}
}

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Export\Helper;
use Chill\MainBundle\Entity\Language;
use Chill\MainBundle\Export\Helper\ExportAddressHelper;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\CivilityRepositoryInterface;
@ -42,7 +43,7 @@ use function strlen;
class ListPersonHelper
{
public const FIELDS = [
'id',
'personId',
'civility',
'firstName',
'lastName',
@ -114,7 +115,26 @@ class ListPersonHelper
}
/**
* @param array|value-of<self::FIELDS>[] $fields
* Those keys are the "direct" keys, which are created when we decide to use to list all the keys.
*
* This method must be used in `getKeys` instead of the `self::FIELDS`
*
* @return array<string>
*/
public function getAllKeys(): array
{
return [
...array_filter(
ListPersonHelper::FIELDS,
fn (string $key) => !in_array($key, ['address_fields', 'lifecycleUpdate'], true)
),
...$this->addressHelper->getKeys(ExportAddressHelper::F_ALL, 'address_fields'),
...['createdAt', 'createdBy', 'updatedAt', 'updatedBy'],
];
}
/**
* @param array<value-of<self::FIELDS>> $fields
*/
public function addSelect(QueryBuilder $qb, array $fields, DateTimeImmutable $computedDate): void
{
@ -124,6 +144,11 @@ class ListPersonHelper
}
switch ($f) {
case 'personId':
$qb->addSelect('person.id AS personId');
break;
case 'countryOfBirth':
case 'nationality':
$qb->addSelect(sprintf('IDENTITY(person.%s) as %s', $f, $f));
@ -138,25 +163,7 @@ class ListPersonHelper
break;
case 'spokenLanguages':
$qb
->leftJoin('person.spokenLanguages', 'spokenLanguage')
->addSelect('AGGREGATE(spokenLanguage.id) AS spokenLanguages')
->addGroupBy('person');
if (in_array('center', $fields, true)) {
$qb->addGroupBy('center');
}
if (in_array('address_fields', $fields, true)) {
$qb
->addGroupBy('address_fieldsid')
->addGroupBy('address_fieldscountry_t.id')
->addGroupBy('address_fieldspostcode_t.id');
}
if (in_array('household_id', $fields, true)) {
$qb->addGroupBy('household_id');
}
$qb->addSelect('(SELECT AGGREGATE(language.id) FROM ' . Language::class . ' language WHERE language MEMBER OF person.spokenLanguages) AS spokenLanguages');
break;

View File

@ -12,107 +12,93 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use DateTime;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Repository\AccompanyingPeriodACLAwareRepositoryTest;
use Symfony\Component\Security\Core\Security;
use function count;
final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodACLAwareRepositoryInterface
/**
* @see AccompanyingPeriodACLAwareRepositoryTest
*/
final readonly class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodACLAwareRepositoryInterface
{
private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private AuthorizationHelper $authorizationHelper;
private AuthorizationHelperForCurrentUserInterface $authorizationHelper;
private CenterResolverDispatcherInterface $centerResolverDispatcher;
private CenterResolverManagerInterface $centerResolver;
private Security $security;
public function __construct(
AccompanyingPeriodRepository $accompanyingPeriodRepository,
Security $security,
AuthorizationHelper $authorizationHelper,
CenterResolverDispatcherInterface $centerResolverDispatcher
AuthorizationHelperForCurrentUserInterface $authorizationHelper,
CenterResolverManagerInterface $centerResolverDispatcher
) {
$this->accompanyingPeriodRepository = $accompanyingPeriodRepository;
$this->security = $security;
$this->authorizationHelper = $authorizationHelper;
$this->centerResolverDispatcher = $centerResolverDispatcher;
$this->centerResolver = $centerResolverDispatcher;
}
/**
* @param array|PostalCode[]
*
* @return QueryBuilder
*/
public function buildQueryOpenedAccompanyingCourseByUser(?User $user, array $postalCodes = [])
public function buildQueryOpenedAccompanyingCourseByUserAndPostalCodes(?User $user, array $postalCodes = []): QueryBuilder
{
$qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap');
$qb->where($qb->expr()->eq('ap.user', ':user'))
->andWhere(
$qb->expr()->neq('ap.step', ':draft'),
$qb->expr()->orX(
$qb->expr()->isNull('ap.closingDate'),
$qb->expr()->gt('ap.closingDate', ':now')
)
$qb->expr()->neq('ap.step', ':closed'),
)
->setParameter('user', $user)
->setParameter('now', new DateTime('now'))
->setParameter('draft', AccompanyingPeriod::STEP_DRAFT);
->setParameter('draft', AccompanyingPeriod::STEP_DRAFT)
->setParameter('closed', AccompanyingPeriod::STEP_CLOSED);
if ([] !== $postalCodes) {
$qb->join('ap.locationHistories', 'location_history')
->leftJoin(PersonHouseholdAddress::class, 'person_address', Join::WITH, 'IDENTITY(location_history.personLocation) = IDENTITY(person_address.person)')
$qb->join('ap.locationHistories', 'location_history', Join::WITH, 'location_history.endDate IS NULL')
->leftJoin(Person\PersonCurrentAddress::class, 'person_address', Join::WITH, 'IDENTITY(location_history.personLocation) = IDENTITY(person_address.person)')
->join(
Address::class,
'address',
Join::WITH,
'COALESCE(IDENTITY(location_history.addressLocation), IDENTITY(person_address.address)) = address.id'
'COALESCE(IDENTITY(person_address.address), IDENTITY(location_history.addressLocation)) = address.id'
)
->join('address.postcode', 'postcode')
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('person_address'),
$qb->expr()->andX(
$qb->expr()->lte('person_address.validFrom', ':now'),
$qb->expr()->orX(
$qb->expr()->isNull('person_address.validTo'),
$qb->expr()->lt('person_address.validTo', ':now')
$qb->expr()->in('postcode.code', ':postal_codes')
)
)
)
)
->andWhere(
$qb->expr()->isNull('location_history.endDate')
)
->andWhere(
$qb->expr()->in('address.postcode', ':postal_codes')
)
->setParameter('now', new DateTimeImmutable('now'), Types::DATE_IMMUTABLE)
->setParameter('postal_codes', $postalCodes);
->setParameter('postal_codes', array_map(fn (PostalCode $postalCode) => $postalCode->getCode(), $postalCodes));
}
return $qb;
}
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function countByUnDispatched(array $jobs, array $services, array $administrativeLocations): int
{
$qb = $this->addACLByUnDispatched($this->buildQueryUnDispatched($jobs, $services, $administrativeLocations));
$qb = $this->addACLMultiCenterOnQuery(
$this->buildQueryUnDispatched($jobs, $services, $administrativeLocations),
$this->buildCenterOnScope()
);
$qb->select('COUNT(ap)');
@ -125,22 +111,12 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
return 0;
}
return $this->buildQueryOpenedAccompanyingCourseByUser($user, $postalCodes)
->select('COUNT(ap)')
->getQuery()
->getSingleScalarResult();
}
$qb = $this->buildQueryOpenedAccompanyingCourseByUserAndPostalCodes($user, $postalCodes);
$qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false);
public function countByUserOpenedAccompanyingPeriod(?User $user): int
{
if (null === $user) {
return 0;
}
$qb->select('COUNT(DISTINCT ap)');
return $this->buildQueryOpenedAccompanyingCourseByUser($user)
->select('COUNT(ap)')
->getQuery()
->getSingleScalarResult();
return $qb->getQuery()->getSingleScalarResult();
}
public function findByPerson(
@ -152,10 +128,14 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
): array {
$qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap');
$scopes = $this->authorizationHelper
->getReachableCircles(
$this->security->getUser(),
->getReachableScopes(
$role,
$this->centerResolverDispatcher->resolveCenter($person)
$this->centerResolver->resolveCenters($person)
);
$scopesCanSeeConfidential = $this->authorizationHelper
->getReachableScopes(
AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL,
$this->centerResolver->resolveCenters($person)
);
if (0 === count($scopes)) {
@ -165,12 +145,44 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
$qb
->join('ap.participations', 'participation')
->where($qb->expr()->eq('participation.person', ':person'))
->andWhere(
$qb->expr()->orX(
'ap.confidential = FALSE',
$qb->expr()->eq('ap.user', ':user')
)
)
->setParameter('person', $person);
$qb = $this->addACLClauses($qb, $scopes, $scopesCanSeeConfidential);
$qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset);
return $qb->getQuery()->getResult();
}
public function addOrderLimitClauses(QueryBuilder $qb, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): QueryBuilder
{
if (null !== $orderBy) {
foreach ($orderBy as $field => $order) {
$qb->addOrderBy('ap.' . $field, $order);
}
}
if (null !== $limit) {
$qb->setMaxResults($limit);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
return $qb;
}
/**
* Add clause for scope on a query, based on no
*
* @param QueryBuilder $qb where the accompanying period have the `ap` alias
* @param array<Scope> $scopesCanSee
* @param array<Scope> $scopesCanSeeConfidential
* @return QueryBuilder
*/
public function addACLClauses(QueryBuilder $qb, array $scopesCanSee, array $scopesCanSeeConfidential): QueryBuilder
{
$qb
->andWhere(
$qb->expr()->orX(
$qb->expr()->neq('ap.step', ':draft'),
@ -181,40 +193,67 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
)
)
->setParameter('draft', AccompanyingPeriod::STEP_DRAFT)
->setParameter('person', $person)
->setParameter('user', $this->security->getUser())
->setParameter('creator', $this->security->getUser());
// add join condition for scopes
$orx = $qb->expr()->orX(
// even if the scope is not in one authorized, the user can see the course if it is in DRAFT state
$qb->expr()->eq('ap.step', ':draft')
);
foreach ($scopes as $key => $scope) {
$orx->add($qb->expr()->orX(
foreach ($scopesCanSee as $key => $scope) {
// for each scope:
// - either the user is the referrer of the course
// - or the accompanying course is one of the reachable scopes
// - and the parcours is not confidential OR the user is the referrer OR the user can see the confidential course
$orOnScope = $qb->expr()->orX(
$qb->expr()->isMemberOf(':scope_' . $key, 'ap.scopes'),
$qb->expr()->eq('ap.user', ':user')
));
);
if (in_array($scope, $scopesCanSeeConfidential, true)) {
$orx->add($orOnScope);
} else {
// we must add a condition: the course is not confidential or the user is the referrer
$andXOnScope = $qb->expr()->andX(
$orOnScope,
$qb->expr()->orX(
'ap.confidential = FALSE',
$qb->expr()->eq('ap.user', ':user')
)
);
$orx->add($andXOnScope);
}
$qb->setParameter('scope_' . $key, $scope);
$qb->setParameter('user', $this->security->getUser());
}
$qb->andWhere($orx);
return $qb->getQuery()->getResult();
return $qb;
}
public function findByUnDispatched(array $jobs, array $services, array $administrativeLocations, ?int $limit = null, ?int $offset = null): array
public function buildCenterOnScope(): array
{
$qb = $this->addACLByUnDispatched($this->buildQueryUnDispatched($jobs, $services, $administrativeLocations));
$centerOnScopes = [];
foreach ($this->authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE) as $center) {
$centerOnScopes[] = [
'center' => $center,
'scopeOnRole' => $this->authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center),
'scopeCanSeeConfidential' => $this->authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center),
];
}
return $centerOnScopes;
}
public function findByUnDispatched(array $jobs, array $services, array $administrativeAdministrativeLocations, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
$qb = $this->buildQueryUnDispatched($jobs, $services, $administrativeAdministrativeLocations);
$qb->select('ap');
if (null !== $limit) {
$qb->setMaxResults($limit);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
$qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false);
$qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset);
return $qb->getQuery()->getResult();
}
@ -225,76 +264,80 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
return [];
}
$qb = $this->buildQueryOpenedAccompanyingCourseByUser($user);
$qb->setFirstResult($offset)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('ap.' . $field, $direction);
}
$qb = $this->buildQueryOpenedAccompanyingCourseByUserAndPostalCodes($user, $postalCodes);
$qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false);
$qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset);
return $qb->getQuery()->getResult();
}
/**
* @return array|AccompanyingPeriod[]
* @param QueryBuilder $qb
* @param list<array{center: Center, scopeOnRole: list<Scope>, scopeCanSeeConfidential: list<Scope>}> $centerScopes
* @param bool $allowNoCenter if true, will allow to see the periods linked to person which does not have any center. Very few edge case when some Person are not associated to a center.
* @return QueryBuilder
*/
public function findByUserOpenedAccompanyingPeriod(?User $user, array $orderBy = [], int $limit = 0, int $offset = 50): array
public function addACLMultiCenterOnQuery(QueryBuilder $qb, array $centerScopes, bool $allowNoCenter = false): QueryBuilder
{
if (null === $user) {
return [];
}
$user = $this->security->getUser();
$qb = $this->buildQueryOpenedAccompanyingCourseByUser($user);
$qb->setFirstResult($offset)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('ap.' . $field, $direction);
}
return $qb->getQuery()->getResult();
}
private function addACLByUnDispatched(QueryBuilder $qb): QueryBuilder
{
$centers = $this->authorizationHelper->getReachableCenters(
$this->security->getUser(),
AccompanyingPeriodVoter::SEE
);
$orX = $qb->expr()->orX();
if (0 === count($centers)) {
if (0 === count($centerScopes) || !$user instanceof User) {
return $qb->andWhere("'FALSE' = 'TRUE'");
}
foreach ($centers as $key => $center) {
$scopes = $this->authorizationHelper
->getReachableCircles(
$this->security->getUser(),
AccompanyingPeriodVoter::SEE,
$center
);
$orX = $qb->expr()->orX();
$idx = 0;
foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) {
$and = $qb->expr()->andX(
$qb->expr()->exists('SELECT part FROM ' . AccompanyingPeriodParticipation::class . ' part ' .
"JOIN part.person p WHERE part.accompanyingPeriod = ap.id AND p.center = :center_{$key}")
$qb->expr()->exists(
'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . " part_{$idx} " .
"JOIN part_{$idx}.person p{$idx} LEFT JOIN p{$idx}.centerCurrent centerCurrent_{$idx} " .
"WHERE part_{$idx}.accompanyingPeriod = ap.id AND (centerCurrent_{$idx}.center = :center_{$idx}"
. ($allowNoCenter ? " OR centerCurrent_{$idx}.id IS NULL)" : ")")
)
);
$qb->setParameter('center_' . $key, $center);
$orScope = $qb->expr()->orX();
$qb->setParameter('center_' . $idx, $center);
foreach ($scopes as $skey => $scope) {
$orScope->add(
$qb->expr()->isMemberOf(':scope_' . $key . '_' . $skey, 'ap.scopes')
$orScopeInsideCenter = $qb->expr()->orX(
// even if the scope is not in one authorized, the user can see the course if it is in DRAFT state
$qb->expr()->eq('ap.step', ':draft')
);
$qb->setParameter('scope_' . $key . '_' . $skey, $scope);
$idx++;
foreach ($scopes as $scope) {
// for each scope:
// - either the user is the referrer of the course
// - or the accompanying course is one of the reachable scopes
// - and the parcours is not confidential OR the user is the referrer OR the user can see the confidential course
$orOnScope = $qb->expr()->orX(
$qb->expr()->isMemberOf(':scope_' . $idx, 'ap.scopes'),
$qb->expr()->eq('ap.user', ':user_executing')
);
$qb->setParameter('user_executing', $user);
if (in_array($scope, $scopesCanSeeConfidential, true)) {
$orScopeInsideCenter->add($orOnScope);
} else {
// we must add a condition: the course is not confidential or the user is the referrer
$andXOnScope = $qb->expr()->andX(
$orOnScope,
$qb->expr()->orX(
'ap.confidential = FALSE',
$qb->expr()->eq('ap.user', ':user_executing')
)
);
$orScopeInsideCenter->add($andXOnScope);
}
$qb->setParameter('scope_' . $idx, $scope);
$idx++;
}
$and->add($orScope);
$and->add($orScopeInsideCenter);
$orX->add($and);
$idx++;
}
return $qb->andWhere($orX);
@ -305,7 +348,7 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
* @param array|Scope[] $services
* @param array|Location[] $locations
*/
private function buildQueryUnDispatched(array $jobs, array $services, array $locations): QueryBuilder
public function buildQueryUnDispatched(array $jobs, array $services, array $locations): QueryBuilder
{
$qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap');
@ -333,8 +376,8 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
$or = $qb->expr()->orX();
foreach ($services as $key => $service) {
$or->add($qb->expr()->isMemberOf(':scope_' . $key, 'ap.scopes'));
$qb->setParameter('scope_' . $key, $service);
$or->add($qb->expr()->isMemberOf(':scopef_' . $key, 'ap.scopes'));
$qb->setParameter('scopef_' . $key, $service);
}
$qb->andWhere($or);
}

View File

@ -31,8 +31,9 @@ interface AccompanyingPeriodACLAwareRepositoryInterface
*/
public function countByUserAndPostalCodesOpenedAccompanyingPeriod(?User $user, array $postalCodes): int;
public function countByUserOpenedAccompanyingPeriod(?User $user): int;
/**
* @return array<AccompanyingPeriod>
*/
public function findByPerson(
Person $person,
string $role,
@ -45,14 +46,13 @@ interface AccompanyingPeriodACLAwareRepositoryInterface
* @param array|UserJob[] $jobs if empty, does not take this argument into account
* @param array|Scope[] $services if empty, does not take this argument into account
*
* @return array|AccompanyingPeriod[]
* @return list<AccompanyingPeriod>
*/
public function findByUnDispatched(array $jobs, array $services, array $administrativeLocations, ?int $limit = null, ?int $offset = null): array;
public function findByUnDispatched(array $jobs, array $services, array $administrativeAdministrativeLocations, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
/**
* @param array|PostalCode[] $postalCodes
* @return list<AccompanyingPeriod>
*/
public function findByUserAndPostalCodesOpenedAccompanyingPeriod(?User $user, array $postalCodes, array $orderBy = [], int $limit = 0, int $offset = 50): array;
public function findByUserOpenedAccompanyingPeriod(?User $user, array $orderBy = [], int $limit = 0, int $offset = 50): array;
}

View File

@ -1,9 +1,11 @@
<div class="accompanying-course-work">
{% for w in works | slice(0,5) %}
<a href="{{ chill_path_add_return_path('chill_person_accompanying_period_work_edit', { 'id': w.id }) }}"
class="dashboard-link" title="{{ 'crud.social_action.title_link'|trans }}">
<a href="{%- if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', w) -%}
{{- chill_path_add_return_path('chill_person_accompanying_period_work_edit', { 'id': w.id }) -}}
{%- elseif is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_SEE', w) -%}
{{- chill_path_add_return_path('chill_person_accompanying_period_work_show', { 'id': w.id }) -}}
{%- else %}#{% endif -%}" class="dashboard-link" title="{{ 'crud.social_action.title_link'|trans }}">
<div class="dashboard">
<span class="title_label"></span>
<span class="title_action"><span class="like-h3">{{ w.socialAction|chill_entity_render_string }}</span>

View File

@ -42,11 +42,6 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
self::RE_OPEN_COURSE,
];
/**
* Give the ability to see all confidential courses.
*/
public const CONFIDENTIAL_CRUD = 'CHILL_PERSON_ACCOMPANYING_PERIOD_CRUD_CONFIDENTIAL';
public const CREATE = 'CHILL_PERSON_ACCOMPANYING_PERIOD_CREATE';
/**
@ -107,6 +102,11 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
*/
public const TOGGLE_INTENSITY = 'CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_INTENSITY';
/**
* Right to see confidential period even if not referrer
*/
public const SEE_CONFIDENTIAL_ALL = 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_CONFIDENTIAL';
private Security $security;
private VoterHelperInterface $voterHelper;
@ -131,7 +131,6 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
return [
self::SEE,
self::SEE_DETAILS,
self::CONFIDENTIAL_CRUD,
self::CREATE,
self::EDIT,
self::DELETE,
@ -139,6 +138,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
self::TOGGLE_CONFIDENTIAL_ALL,
self::REASSIGN_BULK,
self::STATS,
self::SEE_CONFIDENTIAL_ALL,
];
}
@ -149,7 +149,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
public function getRolesWithoutScope(): array
{
return [self::REASSIGN_BULK];
return [];
}
protected function supports($attribute, $subject)
@ -216,7 +216,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
// if confidential, only the referent can see it
if ($subject->isConfidential()) {
if ($this->voterHelper->voteOnAttribute(self::CONFIDENTIAL_CRUD, $subject, $token)) {
if ($this->voterHelper->voteOnAttribute(self::SEE_CONFIDENTIAL_ALL, $subject, $token)) {
return true;
}

View File

@ -360,10 +360,12 @@ final class PersonContext implements PersonContextInterface
private function isScopeNecessary(Person $person): bool
{
if ($this->showScopes && 1 < $this->authorizationHelper->getReachableScopes(
if ($this->showScopes && 1 < count(
$this->authorizationHelper->getReachableScopes(
$this->security->getUser(),
PersonDocumentVoter::CREATE,
$this->centerResolverManager->resolveCenters($person)
)
)) {
return true;
}

View File

@ -0,0 +1,517 @@
<?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 Repository;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepository;
use Chill\PersonBundle\Repository\AccompanyingPeriodRepository;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Registry;
/**
* @internal
* @coversNothing
*/
class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase
{
use ProphecyTrait;
private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private CenterResolverManagerInterface $centerResolverManager;
private CenterRepositoryInterface $centerRepository;
private EntityManagerInterface $entityManager;
private ScopeRepositoryInterface $scopeRepository;
private Registry $registry;
private static array $periodsIdsToDelete = [];
protected function setUp(): void
{
self::bootKernel();
$this->accompanyingPeriodRepository = self::$container->get(AccompanyingPeriodRepository::class);
$this->centerRepository = self::$container->get(CenterRepositoryInterface::class);
$this->centerResolverManager = self::$container->get(CenterResolverManagerInterface::class);
$this->entityManager = self::$container->get(EntityManagerInterface::class);
$this->scopeRepository = self::$container->get(ScopeRepositoryInterface::class);
$this->registry = self::$container->get(Registry::class);
}
public static function tearDownAfterClass(): void
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
$repository = self::$container->get(AccompanyingPeriodRepository::class);
foreach (self::$periodsIdsToDelete as $id) {
if (null === $period = $repository->find($id)) {
throw new \RuntimeException("period not found while trying to delete it");
}
foreach ($period->getParticipations() as $participation) {
$em->remove($participation);
}
$em->remove($period);
}
//$em->flush();
}
/**
* @dataProvider provideDataFindByUserAndPostalCodesOpenedAccompanyingPeriod
* @param list<array{center: Center, scopeOnRole: list<Scope>, scopeCanSeeConfidential: list<Scope>}> $centerScopes
* @param list<AccompanyingPeriod> $expectedContains
* @param list<AccompanyingPeriod> $expectedNotContains
*/
public function testFindByUserAndPostalCodesOpenedAccompanyingPeriod(User $user, User $searched, array $centerScopes, array $expectedContains, array $expectedNotContains, string $message): void
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$centers = [];
foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) {
$centers[spl_object_hash($center)] = $center;
$authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center)
->willReturn($scopes);
$authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center)
->willReturn($scopesCanSeeConfidential);
}
$authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE)->willReturn(array_values($centers));
$repository = new AccompanyingPeriodACLAwareRepository(
$this->accompanyingPeriodRepository,
$security->reveal(),
$authorizationHelper->reveal(),
$this->centerResolverManager
);
$actual = array_map(
fn (AccompanyingPeriod $period) => $period->getId(),
$repository->findByUserAndPostalCodesOpenedAccompanyingPeriod($searched, [], ['id' => 'DESC'], 20, 0)
);
foreach ($expectedContains as $expected) {
self::assertContains($expected->getId(), $actual, $message);
}
foreach ($expectedNotContains as $expected) {
self::assertNotContains($expected->getId(), $actual, $message);
}
}
public function provideDataFindByUserAndPostalCodesOpenedAccompanyingPeriod(): iterable
{
$this->setUp();
if (null === $user = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u")->setMaxResults(1)->getSingleResult()) {
throw new \RuntimeException("no user found");
}
if (null === $anotherUser = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u WHERE u.id != :uid")->setParameter('uid', $user->getId())
->setMaxResults(1)->getSingleResult()) {
throw new \RuntimeException("no user found");
}
/** @var Person $person */
[$person, $anotherPerson, $person2, $person3] = $this->entityManager
->createQuery("SELECT p FROM " . Person::class . " p JOIN p.centerCurrent current_center")
->setMaxResults(4)
->getResult();
if (null === $person || null === $anotherPerson || null === $person2 || null === $person3) {
throw new \RuntimeException("no person found");
}
$scopes = $this->scopeRepository->findAll();
if (3 > count($scopes)) {
throw new \RuntimeException("not enough scopes for this test");
}
$scopesCanSee = [ $scopes[0] ];
$scopesGroup2 = [ $scopes[1] ];
$centers = $this->centerRepository->findActive();
$aCenterNotAssociatedToPerson = array_values(array_filter($centers, fn (Center $c) => $c !== $person->getCenter()))[0];
if (2 > count($centers)) {
throw new \RuntimeException("not enough centers for this test");
}
$period = $this->buildPeriod($person, $scopesCanSee, $user, true);
$period->setUser($user);
yield [
$anotherUser,
$user,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesCanSee,
'scopeCanSeeConfidential' => [],
],
],
[$period],
[],
"period should be visible with expected scopes",
];
yield [
$anotherUser,
$user,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesGroup2,
'scopeCanSeeConfidential' => [],
],
],
[],
[$period],
"period should not be visible without expected scopes",
];
yield [
$anotherUser,
$user,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesGroup2,
'scopeCanSeeConfidential' => [],
],
[
'center' => $aCenterNotAssociatedToPerson,
'scopeOnRole' => $scopesCanSee,
'scopeCanSeeConfidential' => [],
],
],
[],
[$period],
"period should not be visible for user having right in another scope (with multiple centers)"
];
$period = $this->buildPeriod($person, $scopesCanSee, $user, true);
$period->setUser($user);
$period->setConfidential(true);
yield [
$anotherUser,
$user,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesCanSee,
'scopeCanSeeConfidential' => [],
],
],
[],
[$period],
"period confidential should not be visible",
];
yield [
$anotherUser,
$user,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesCanSee,
'scopeCanSeeConfidential' => $scopesCanSee,
],
],
[$period],
[],
"period confidential be visible if user has required scopes",
];
$this->entityManager->flush();
}
/**
* @dataProvider provideDataFindByUndispatched
* @param list<array{center: Center, scopeOnRole: list<Scope>, scopeCanSeeConfidential: list<Scope>}> $centerScopes
* @param list<AccompanyingPeriod> $expectedContains
* @param list<AccompanyingPeriod> $expectedNotContains
*/
public function testFindByUndispatched(User $user, array $centerScopes, array $expectedContains, array $expectedNotContains, string $message): void
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$centers = [];
foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) {
$centers[spl_object_hash($center)] = $center;
$authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center)
->willReturn($scopes);
$authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center)
->willReturn($scopesCanSeeConfidential);
}
$authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE)->willReturn(array_values($centers));
$repository = new AccompanyingPeriodACLAwareRepository(
$this->accompanyingPeriodRepository,
$security->reveal(),
$authorizationHelper->reveal(),
$this->centerResolverManager
);
$actual = array_map(
fn (AccompanyingPeriod $period) => $period->getId(),
$repository->findByUnDispatched([], [], [], ['id' => 'DESC'], 20, 0)
);
foreach ($expectedContains as $expected) {
self::assertContains($expected->getId(), $actual, $message);
}
foreach ($expectedNotContains as $expected) {
self::assertNotContains($expected->getId(), $actual, $message);
}
}
public function provideDataFindByUndispatched(): iterable
{
$this->setUp();
if (null === $user = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u")->setMaxResults(1)->getSingleResult()) {
throw new \RuntimeException("no user found");
}
if (null === $anotherUser = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u WHERE u.id != :uid")->setParameter('uid', $user->getId())
->setMaxResults(1)->getSingleResult()) {
throw new \RuntimeException("no user found");
}
/** @var Person $person */
[$person, $anotherPerson, $person2, $person3] = $this->entityManager
->createQuery("SELECT p FROM " . Person::class . " p ")
->setMaxResults(4)
->getResult();
if (null === $person || null === $anotherPerson || null === $person2 || null === $person3) {
throw new \RuntimeException("no person found");
}
$scopes = $this->scopeRepository->findAll();
if (3 > count($scopes)) {
throw new \RuntimeException("not enough scopes for this test");
}
$scopesCanSee = [ $scopes[0] ];
$scopesGroup2 = [ $scopes[1] ];
$centers = $this->centerRepository->findActive();
if (2 > count($centers)) {
throw new \RuntimeException("not enough centers for this test");
}
$period = $this->buildPeriod($person, $scopesCanSee, $user, true);
// expected scope: can see the period
yield [
$anotherUser,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesCanSee,
'scopeCanSeeConfidential' => [],
],
],
[$period],
[],
"period should be visible with expected scopes",
];
// no scope visible
yield [
$anotherUser,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesGroup2,
'scopeCanSeeConfidential' => [],
],
],
[],
[$period],
"period should not be visible without expected scopes",
];
// another center
yield [
$anotherUser,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesGroup2,
'scopeCanSeeConfidential' => [],
],
[
'center' => array_values(array_filter($centers, fn (Center $c) => $c !== $person->getCenter()))[0],
'scopeOnRole' => $scopesCanSee,
'scopeCanSeeConfidential' => [],
],
],
[],
[$period],
"period should not be visible for user having right in another scope (with multiple centers)"
];
$this->entityManager->flush();
}
/**
* For testing this method, we mock the authorization helper to return different Scope that a user
* can see, or that a user can see confidential periods.
*
* @param array<Scope> $scopeUserCanSee
* @param array<Scope> $scopeUserCanSeeConfidential
* @param array<AccompanyingPeriod> $expectedPeriod
* @dataProvider provideDataForFindByPerson
*/
public function testFindByPersonTestUser(User $user, Person $person, array $scopeUserCanSee, array $scopeUserCanSeeConfidential, array $expectedPeriod, string $message): void
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, Argument::any())
->willReturn($scopeUserCanSee);
$authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, Argument::any())
->willReturn($scopeUserCanSeeConfidential);
$repository = new AccompanyingPeriodACLAwareRepository(
$this->accompanyingPeriodRepository,
$security->reveal(),
$authorizationHelper->reveal(),
$this->centerResolverManager
);
$actuals = $repository->findByPerson($person, AccompanyingPeriodVoter::SEE);
$expectedIds = array_map(fn (AccompanyingPeriod $period) => $period->getId(), $expectedPeriod);
self::assertCount(count($expectedPeriod), $actuals, $message);
foreach ($actuals as $actual) {
self::assertContains($actual->getId(), $expectedIds);
}
}
public function provideDataForFindByPerson(): iterable
{
$this->setUp();
if (null === $user = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u")->setMaxResults(1)->getSingleResult()) {
throw new \RuntimeException("no user found");
}
if (null === $anotherUser = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u WHERE u.id != :uid")->setParameter('uid', $user->getId())
->setMaxResults(1)->getSingleResult()) {
throw new \RuntimeException("no user found");
}
[$person, $anotherPerson, $person2, $person3] = $this->entityManager
->createQuery("SELECT p FROM " . Person::class . " p WHERE SIZE(p.accompanyingPeriodParticipations) = 0")
->setMaxResults(4)
->getResult();
if (null === $person || null === $anotherPerson || null === $person2 || null === $person3) {
throw new \RuntimeException("no person found");
}
$scopes = $this->scopeRepository->findAll();
if (3 > count($scopes)) {
throw new \RuntimeException("not enough scopes for this test");
}
$scopesCanSee = [ $scopes[0] ];
$scopesGroup2 = [ $scopes[1] ];
// case: a period is in draft state
$period = $this->buildPeriod($person, $scopesCanSee, $user, false);
yield [$user, $person, $scopesCanSee, [], [$period], "a user can see his period during draft state"];
// another user is not allowed to see this period, because it is in DRAFT state
yield [$anotherUser, $person, $scopesCanSee, [], [], "another user is not allowed to see the period of someone else in draft state"];
// the period is confirmed
$period = $this->buildPeriod($anotherPerson, $scopesCanSee, $user, true);
// the other user can now see it
yield [$user, $anotherPerson, $scopesCanSee, [], [$period], "a user see his period when confirmed"];
yield [$anotherUser, $anotherPerson, $scopesCanSee, [], [$period], "another user with required scopes is allowed to see the period when not draft"];
yield [$anotherUser, $anotherPerson, $scopesGroup2, [], [], "another user without the required scopes is not allowed to see the period when not draft"];
// this period will be confidential
$period = $this->buildPeriod($person2, $scopesCanSee, $user, true);
$period->setConfidential(true)->setUser($user, true);
yield [$user, $person2, $scopesCanSee, [], [$period], "a user see his period when confirmed and confidential with required scopes"];
yield [$user, $person2, $scopesGroup2, [], [$period], "a user see his period when confirmed and confidential without required scopes"];
yield [$anotherUser, $person2, $scopesCanSee, [], [], "a user don't see a confidential period, even if he has required scopes"];
yield [$anotherUser, $person2, $scopesCanSee, $scopesCanSee, [$period], "a user see the period when confirmed and confidential if he has required scope to see the period"];
// period draft with creator = null
$period = $this->buildPeriod($person3, $scopesCanSee, null, false);
yield [$user, $person3, $scopesCanSee, [], [$period], "a user see a period when draft if no creator on the period"];
$this->entityManager->flush();
}
/**
* @param Person $person
* @param array<Scope> $scopes
* @return AccompanyingPeriod
*/
private function buildPeriod(Person $person, array $scopes, User|null $creator, bool $confirm): AccompanyingPeriod
{
$period = new AccompanyingPeriod();
$period->addPerson($person);
if (null !== $creator) {
$period->setCreatedBy($creator);
}
foreach ($scopes as $scope) {
$period->addScope($scope);
}
$this->entityManager->persist($period);
self::$periodsIdsToDelete[] = $period->getId();
if ($confirm) {
$workflow = $this->registry->get($period, 'accompanying_period_lifecycle');
$workflow->apply($period, 'confirm');
}
return $period;
}
}

View File

@ -135,6 +135,14 @@ services:
tags:
- { name: chill.export_filter, alias: accompanyingcourse_user_working_on_filter }
Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\JobWorkingOnCourseFilter:
tags:
- { name: chill.export_filter, alias: accompanyingcourse_job_working_on_filter }
Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\ScopeWorkingOnCourseFilter:
tags:
- { name: chill.export_filter, alias: accompanyingcourse_scope_working_on_filter }
Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\HavingAnAccompanyingPeriodInfoWithinDatesFilter:
tags:
- { name: chill.export_filter, alias: accompanyingcourse_info_within_filter }
@ -231,3 +239,15 @@ services:
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\CreatorJobAggregator:
tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_creator_job_aggregator }
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\UserWorkingOnCourseAggregator:
tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_user_working_on_course_aggregator }
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\JobWorkingOnCourseAggregator:
tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_job_working_on_course_aggregator }
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\ScopeWorkingOnCourseAggregator:
tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_scope_working_on_course_aggregator }

View File

@ -4,35 +4,27 @@ services:
autowire: true
## Indicators
chill.person.export.count_person:
class: Chill\PersonBundle\Export\Export\CountPerson
autowire: true
autoconfigure: true
Chill\PersonBundle\Export\Export\CountPerson:
tags:
- { name: chill.export, alias: count_person }
chill.person.export.count_person_with_accompanying_course:
class: Chill\PersonBundle\Export\Export\CountPersonWithAccompanyingCourse
autowire: true
autoconfigure: true
Chill\PersonBundle\Export\Export\CountPersonWithAccompanyingCourse:
tags:
- { name: chill.export, alias: count_person_with_accompanying_course }
Chill\PersonBundle\Export\Export\ListPerson:
autowire: true
autoconfigure: true
tags:
- { name: chill.export, alias: list_person }
Chill\PersonBundle\Export\Export\ListPersonWithAccompanyingPeriod:
autowire: true
autoconfigure: true
Chill\PersonBundle\Export\Export\ListPersonHavingAccompanyingPeriod:
tags:
- { name: chill.export, alias: list_person_with_acp }
Chill\PersonBundle\Export\Export\ListPersonWithAccompanyingPeriodDetails:
tags:
- { name: chill.export, alias: list_person_with_acp_details }
Chill\PersonBundle\Export\Export\ListAccompanyingPeriod:
autowire: true
autoconfigure: true
tags:
- { name: chill.export, alias: list_acp }
@ -177,3 +169,8 @@ services:
tags:
- { name: chill.export_aggregator, alias: person_household_compo_aggregator }
Chill\PersonBundle\Export\Aggregator\PersonAggregators\CenterAggregator:
tags:
- { name: chill.export_aggregator, alias: person_center_aggregator }

View File

@ -331,6 +331,7 @@ CHILL_PERSON_ACCOMPANYING_PERIOD_FULL: Voir les détails, créer, supprimer et m
CHILL_PERSON_ACCOMPANYING_COURSE_REASSIGN_BULK: Réassigner les parcours en lot
CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_DETAILS: Voir les détails d'un parcours d'accompagnement
CHILL_PERSON_ACCOMPANYING_PERIOD_STATS: Statistiques sur les parcours d'accompagnement
CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_CONFIDENTIAL: Voir les parcours confidentiels
CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_CREATE: Créer une action d'accompagnement
CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_DELETE: Supprimer une action d'accompagnement
@ -372,7 +373,7 @@ Count people participating in an accompanying course by various parameters.: Com
Exports of accompanying courses: Exports des parcours d'accompagnement
Count accompanying courses: Nombre de parcours
Count accompanying courses by various parameters: Compte le nombre de parcours en fonction de différents filtres.
Accompanying courses participation duration and number of participations: Durée moyenne et nombre des participation des usagers aux parcours
Accompanying courses participation duration and number of participations: Durée moyenne et nombre des participations des usagers aux parcours
Create an average of accompanying courses duration of each person participation to accompanying course, according to filters on persons, accompanying course: Crée un rapport qui comptabilise la moyenne de la durée de participation de chaque usager concerné aux parcours, avec différents filtres, notamment sur les usagers concernés.
Closingdate to apply: Date de fin à prendre en compte lorsque le parcours n'est pas clotûré
@ -1000,6 +1001,8 @@ notification:
Notify referrer: Notifier le référent
Notify any: Notifier d'autres utilisateurs
personId: Identifiant de l'usager
export:
export:
acp_stats:
@ -1019,6 +1022,11 @@ export:
Household composition: Composition du ménage
Group course by household composition: Grouper les usagers par composition familiale
Calc date: Date de calcul de la composition du ménage
by_center:
title: Grouper les usagers par centre
at_date: Date de calcul du centre
center: Centre de l'usager
course:
by_referrer:
Computation date for referrer: Date à laquelle le référent était actif
@ -1035,6 +1043,15 @@ export:
Number of actions: Nombre d'actions
by_creator_job:
Creator's job: Métier du créateur
by_user_working:
title: Grouper les parcours par intervenant
user: Intervenant
by_job_working:
title: Grouper les parcours par métier de l'intervenant
job: Métier de l'intervenant
by_scope_working:
title: Grouper les parcours par service de l'intervenant
scope: Service de l'intervenant
course_work:
by_current_action:
Current action ?: Action en cours ?
@ -1084,8 +1101,20 @@ export:
end_date: Fin de la période
Only course with events between %startDate% and %endDate%: Seulement les parcours ayant reçu une intervention entre le %startDate% et le %endDate%
by_user_working:
title: Filter les parcours par intervenant
'Filtered by user working on course: only %users%': 'Filtré par intervenants sur le parcours: seulement %users%'
title: Filter les parcours par intervenant, entre deux dates
'Filtered by user working on course: only %users%, between %start_date% and %end_date%': 'Filtré par intervenants sur le parcours: seulement %users%, entre le %start_date% et le %end_date%'
User working after: Intervention après le
User working before: Intervention avant le
by_job_working:
title: Filtrer les parcours par métier de l'intervenant, entre deux dates
'Filtered by job working on course: only %jobs%, between %start_date% and %end_date%': 'Filtré par métier des intervenants sur le parcours: seulement %jobs%, entre le %start_date% et le %end_date%'
Job working after: Intervention après le
Job working before: Intervention avant le
by_scope_working:
title: Filtrer les parcours par service de l'intervenant, entre deux dates
'Filtered by scope working on course: only %scopes%, between %start_date% and %end_date%': 'Filtré par service des intervenants sur le parcours: seulement %scopes%, entre le %start_date% et le %end_date%'
Scope working after: Intervention après le
Scope working before: Intervention avant le
by_step:
Filter by step: Filtrer les parcours par statut du parcours
Filter by step between dates: Filtrer les parcours par statut du parcours entre deux dates
@ -1123,13 +1152,15 @@ export:
list:
person_with_acp:
List peoples having an accompanying period: Liste des usagers ayant un parcours d'accompagnement
List peoples having an accompanying period with period details: Liste des usagers concernés avec détail de chaque parcours
Create a list of people having an accompaying periods, according to various filters.: Génère une liste des usagers ayant un parcours d'accompagnement, selon différents critères liés au parcours ou à l'usager
Create a list of people having an accompaying periods with details of period, according to various filters.: Génère une liste des usagers ayant un parcours d'accompagnement, selon différents critères liés au parcours ou à l'usager. Ajoute les détails du parcours à la liste.
acp:
List of accompanying periods: Liste des parcours d'accompagnements
Generate a list of accompanying periods, filtered on different parameters.: Génère une liste des parcours d'accompagnement, filtrée sur différents paramètres.
Date of calculation for associated elements: Date de calcul des éléments associés
The associated referree, localisation, and other elements will be valid at this date: Les éléments associés, comme la localisation, le référent et d'autres éléments seront valides à cette date
id: Identifiant du parcours
acpId: Identifiant du parcours
openingDate: Date d'ouverture du parcours
closingDate: Date de fermeture du parcours
closingMotive: Motif de cloture
@ -1137,14 +1168,14 @@ export:
confidential: Confidentiel
emergency: Urgent
intensity: Intensité
createdAt: Créé le
updatedAt: Dernière mise à jour le
acpCreatedAt: Créé le
acpUpdatedAt: Dernière mise à jour le
acpOrigin: Origine du parcours
origin: Origine du parcours
acpClosingMotive: Motif de fermeture
acpJob: Métier du parcours
createdBy: Créé par
updatedBy: Dernière modification par
acpCreatedBy: Créé par
acpUpdatedBy: Dernière modification par
administrativeLocation: Location administrative
step: Etape
stepSince: Dernière modification de l'étape
@ -1152,7 +1183,7 @@ export:
referrerSince: Référent depuis le
locationIsPerson: Parcours localisé auprès d'un usager concerné
locationIsTemp: Parcours avec une localisation temporaire
acpLocationPersonName: Usager auprès duquel le parcours est localisé
locationPersonName: Usager auprès duquel le parcours est localisé
locationPersonId: Identifiant de l'usager auprès duquel le parcours est localisé
acpaddress_fieldscountry: Pays de l'adresse
isRequestorPerson: Le demandeur est-il un usager ?

View File

@ -26,6 +26,7 @@ use Chill\TaskBundle\Event\TaskEvent;
use Chill\TaskBundle\Event\UI\UIEvent;
use Chill\TaskBundle\Form\SingleTaskType;
use Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface;
use Chill\TaskBundle\Repository\SingleTaskStateRepository;
use Chill\TaskBundle\Security\Authorization\TaskVoter;
use LogicException;
use Psr\Log\LoggerInterface;
@ -71,7 +72,8 @@ final class SingleTaskController extends AbstractController
EventDispatcherInterface $eventDispatcher,
TimelineBuilder $timelineBuilder,
LoggerInterface $logger,
FilterOrderHelperFactoryInterface $filterOrderHelperFactory
FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
private SingleTaskStateRepository $singleTaskStateRepository
) {
$this->eventDispatcher = $eventDispatcher;
$this->timelineBuilder = $timelineBuilder;
@ -452,7 +454,7 @@ final class SingleTaskController extends AbstractController
{
$this->denyAccessUnlessGranted('ROLE_USER');
$filterOrder = $this->buildFilterOrder();
$filterOrder = $this->buildFilterOrder(false);
$flags = array_merge(
$filterOrder->getCheckboxData('status'),
array_map(static fn ($i) => 'state_' . $i, $filterOrder->getCheckboxData('states'))
@ -667,7 +669,7 @@ final class SingleTaskController extends AbstractController
return $form;
}
private function buildFilterOrder(): FilterOrderHelper
private function buildFilterOrder($includeFilterByUser = true): FilterOrderHelper
{
$statuses = ['no-alert', 'warning', 'alert'];
$statusTrans = [
@ -675,18 +677,22 @@ final class SingleTaskController extends AbstractController
'Tasks near deadline',
'Tasks over deadline',
];
$states = [
// todo: get a list of possible states dynamically
'new', 'in_progress', 'closed', 'canceled',
];
$states = $this->singleTaskStateRepository->findAllExistingStates();
$checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['closed', 'canceled', 'validated'], true)));
return $this->filterOrderHelperFactory
$filterBuilder = $this->filterOrderHelperFactory
->create(self::class)
->addSearchBox()
->addCheckbox('status', $statuses, $statuses, $statusTrans)
->addCheckbox('states', $states, ['new', 'in_progress'])
->addUserPicker('userPicker', 'Filter by user', ['multiple' => true, 'required' => false])
->build();
->addCheckbox('states', $states, $checked)
;
if ($includeFilterByUser) {
$filterBuilder
->addUserPicker('userPicker', 'Filter by user', ['multiple' => true, 'required' => false]);
}
return $filterBuilder->build();
}
/**

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Repository;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
class SingleTaskStateRepository
{
private const FIND_ALL_STATES = <<<'SQL'
SELECT DISTINCT jsonb_array_elements_text(current_states) FROM chill_task.single_task
SQL;
public function __construct(
private Connection $connection
) {
}
/**
* Return a list of all states associated to at least one single task in the database
*
* @return list<string>
* @throws Exception
*/
public function findAllExistingStates(): array
{
$states = [];
foreach ($this->connection->fetchAllNumeric(self::FIND_ALL_STATES) as $row) {
if ('' !== $row[0] && null !== $row[0]) {
$states[] = $row[0];
}
}
return $states;
}
}

View File

@ -1,4 +1,8 @@
services:
_defaults:
autowire: true
autoconfigure: true
chill_task.single_task_repository:
class: Chill\TaskBundle\Repository\SingleTaskRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
@ -10,8 +14,8 @@ services:
- "@chill.main.security.authorization.helper"
Chill\TaskBundle\Repository\SingleTaskRepository: '@chill_task.single_task_repository'
Chill\TaskBundle\Repository\SingleTaskAclAwareRepository:
autowire: true
autoconfigure: true
Chill\TaskBundle\Repository\SingleTaskAclAwareRepository: ~
Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface: '@Chill\TaskBundle\Repository\SingleTaskAclAwareRepository'
Chill\TaskBundle\Repository\SingleTaskStateRepository: ~

View File

@ -71,12 +71,9 @@ class ThirdPartyApiSearch implements SearchApiInterface
->setSelectKey('tparty')
->setSelectJsonbMetadata("jsonb_build_object('id', tparty.id)")
->setFromClause('chill_3party.third_party AS tparty
LEFT JOIN chill_main_address cma ON cma.id = tparty.address_id
LEFT JOIN chill_main_postal_code cmpc ON cma.postcode_id = cmpc.id
LEFT JOIN chill_3party.third_party AS parent ON tparty.parent_id = parent.id
LEFT JOIN chill_main_address cma_p ON parent.address_id = cma_p.id
LEFT JOIN chill_main_postal_code cmpc_p ON cma_p.postcode_id = cmpc.id')
->andWhereClause('tparty.active IS TRUE');
LEFT JOIN chill_main_address cma ON cma.id = COALESCE(parent.address_id, tparty.address_id)
LEFT JOIN chill_main_postal_code cmpc ON cma.postcode_id = cmpc.id');
$strs = explode(' ', $pattern);
$wheres = [];
@ -102,9 +99,8 @@ class ThirdPartyApiSearch implements SearchApiInterface
(parent.canonicalized LIKE '%s' || LOWER(UNACCENT(?)) || '%')::int
) + " .
// take postcode label into account, but lower than the canonicalized field
"COALESCE((LOWER(UNACCENT(cmpc.label)) LIKE '%' || LOWER(UNACCENT(?)) || '%')::int * 0.3, 0) + " .
"COALESCE((LOWER(UNACCENT(cmpc_p.label)) LIKE '%' || LOWER(UNACCENT(?)) || '%')::int * 0.3, 0)";
$pertinenceArgs[] = [$str, $str, $str, $str, $str, $str];
"COALESCE((LOWER(UNACCENT(cmpc.label)) LIKE '%' || LOWER(UNACCENT(?)) || '%')::int * 0.3, 0)";
$pertinenceArgs[] = [$str, $str, $str, $str, $str];
}
}