diff --git a/.changes/unreleased/DX-20230623-122408.yaml b/.changes/unreleased/DX-20230623-122408.yaml deleted file mode 100644 index 58dd96180..000000000 --- a/.changes/unreleased/DX-20230623-122408.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: DX -body: '[FilterOrderHelper] add entity choice and singleCheckbox' -time: 2023-06-23T12:24:08.133491895+02:00 -custom: - Issue: "" diff --git a/.changes/unreleased/Feature-20230623-122530.yaml b/.changes/unreleased/Feature-20230623-122530.yaml deleted file mode 100644 index 922750ea8..000000000 --- a/.changes/unreleased/Feature-20230623-122530.yaml +++ /dev/null @@ -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: "" diff --git a/.changes/unreleased/Feature-20230623-122702.yaml b/.changes/unreleased/Feature-20230623-122702.yaml deleted file mode 100644 index e1d1b0e1f..000000000 --- a/.changes/unreleased/Feature-20230623-122702.yaml +++ /dev/null @@ -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: "" diff --git a/.changes/unreleased/Feature-20230623-124438.yaml b/.changes/unreleased/Feature-20230623-124438.yaml deleted file mode 100644 index bc199d3bb..000000000 --- a/.changes/unreleased/Feature-20230623-124438.yaml +++ /dev/null @@ -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: "" diff --git a/.changes/unreleased/Feature-20230707-123609.yaml b/.changes/unreleased/Feature-20230707-123609.yaml new file mode 100644 index 000000000..51ff94d4c --- /dev/null +++ b/.changes/unreleased/Feature-20230707-123609.yaml @@ -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" diff --git a/.changes/unreleased/Feature-20230707-124132.yaml b/.changes/unreleased/Feature-20230707-124132.yaml new file mode 100644 index 000000000..4ad93ad22 --- /dev/null +++ b/.changes/unreleased/Feature-20230707-124132.yaml @@ -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: "" diff --git a/.changes/unreleased/Feature-20230711-150055.yaml b/.changes/unreleased/Feature-20230711-150055.yaml new file mode 100644 index 000000000..ecee61b49 --- /dev/null +++ b/.changes/unreleased/Feature-20230711-150055.yaml @@ -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" diff --git a/.changes/unreleased/Feature-20230711-155929.yaml b/.changes/unreleased/Feature-20230711-155929.yaml new file mode 100644 index 000000000..329bbb677 --- /dev/null +++ b/.changes/unreleased/Feature-20230711-155929.yaml @@ -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" diff --git a/.changes/unreleased/Fixed-20230628-170055.yaml b/.changes/unreleased/Fixed-20230628-170055.yaml deleted file mode 100644 index 7f9ec3028..000000000 --- a/.changes/unreleased/Fixed-20230628-170055.yaml +++ /dev/null @@ -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: "" diff --git a/.changes/unreleased/Fixed-20230629-124412.yaml b/.changes/unreleased/Fixed-20230629-124412.yaml deleted file mode 100644 index 7fc3d3eb0..000000000 --- a/.changes/unreleased/Fixed-20230629-124412.yaml +++ /dev/null @@ -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: "" diff --git a/.changes/unreleased/Fixed-20230629-231503.yaml b/.changes/unreleased/Fixed-20230629-231503.yaml deleted file mode 100644 index e021d1fda..000000000 --- a/.changes/unreleased/Fixed-20230629-231503.yaml +++ /dev/null @@ -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: "" diff --git a/.changes/unreleased/Fixed-20230630-171119.yaml b/.changes/unreleased/Fixed-20230630-171119.yaml deleted file mode 100644 index f3185ace2..000000000 --- a/.changes/unreleased/Fixed-20230630-171119.yaml +++ /dev/null @@ -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: "" diff --git a/.changes/unreleased/Fixed-20230630-171153.yaml b/.changes/unreleased/Fixed-20230630-171153.yaml deleted file mode 100644 index c09bd93d0..000000000 --- a/.changes/unreleased/Fixed-20230630-171153.yaml +++ /dev/null @@ -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: "" diff --git a/.changes/unreleased/Fixed-20230712-090514.yaml b/.changes/unreleased/Fixed-20230712-090514.yaml new file mode 100644 index 000000000..51a8b9317 --- /dev/null +++ b/.changes/unreleased/Fixed-20230712-090514.yaml @@ -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: "" diff --git a/.changes/unreleased/Fixed-20230713-102640.yaml b/.changes/unreleased/Fixed-20230713-102640.yaml new file mode 100644 index 000000000..e731e5252 --- /dev/null +++ b/.changes/unreleased/Fixed-20230713-102640.yaml @@ -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" diff --git a/.changes/v2.4.0.md b/.changes/v2.4.0.md new file mode 100644 index 000000000..522957300 --- /dev/null +++ b/.changes/v2.4.0.md @@ -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 diff --git a/.changie.yaml b/.changie.yaml index 8a25ed695..cda69de65 100644 --- a/.changie.yaml +++ b/.changie.yaml @@ -30,6 +30,8 @@ kinds: auto: patch - label: DX auto: patch + - label: UX + auto: patch newlines: afterChangelogHeader: 1 beforeChangelogVersion: 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 93ff93556..1bb9a8ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/exports_alias_conventions.md b/exports_alias_conventions.md index fd7844691..64df91030 100644 --- a/exports_alias_conventions.md +++ b/exports_alias_conventions.md @@ -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 | diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index 63639c149..1e911ff08 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -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,21 +327,28 @@ final class ActivityController extends AbstractController $filterBuilder ->addDateRange('activity_date', 'activity.date') - ->addSingleCheckbox('my_activities', 'activity_filter.My activities') - ->addEntityChoice('activity_types', 'activity_filter.Types', \Chill\ActivityBundle\Entity\ActivityType::class, $types, [ - 'choice_label' => function (\Chill\ActivityBundle\Entity\ActivityType $activityType) { - $text = match ($activityType->hasCategory()) { - true => $this->translatableStringHelper->localize($activityType->getCategory()->getName()) . ' > ', - false => '', - }; + ->addSingleCheckbox('my_activities', 'activity_filter.My activities'); - return $text . $this->translatableStringHelper->localize($activityType->getName()); - } - ]) - ->addEntityChoice('jobs', 'activity_filter.Jobs', UserJob::class, $jobs, [ - 'choice_label' => fn (UserJob $u) => $this->translatableStringHelper->localize($u->getLabel()) - ]) - ; + 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()) { + true => $this->translatableStringHelper->localize($activityType->getCategory()->getName()) . ' > ', + false => '', + }; + + 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(); } diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityLocationAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityLocationAggregator.php new file mode 100644 index 000000000..9103943e4 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityLocationAggregator.php @@ -0,0 +1,80 @@ +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'; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/PeriodHavingActivityBetweenDatesFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/PeriodHavingActivityBetweenDatesFilter.php new file mode 100644 index 000000000..27e012d0b --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Filter/ACPFilters/PeriodHavingActivityBetweenDatesFilter.php @@ -0,0 +1,90 @@ +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; + } +} diff --git a/src/Bundle/ChillActivityBundle/Timeline/TimelineActivityProvider.php b/src/Bundle/ChillActivityBundle/Timeline/TimelineActivityProvider.php index dad597676..2c9272b08 100644 --- a/src/Bundle/ChillActivityBundle/Timeline/TimelineActivityProvider.php +++ b/src/Bundle/ChillActivityBundle/Timeline/TimelineActivityProvider.php @@ -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; } diff --git a/src/Bundle/ChillActivityBundle/config/services/export.yaml b/src/Bundle/ChillActivityBundle/config/services/export.yaml index 09817d80e..5af2895e9 100644 --- a/src/Bundle/ChillActivityBundle/config/services/export.yaml +++ b/src/Bundle/ChillActivityBundle/config/services/export.yaml @@ -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: diff --git a/src/Bundle/ChillActivityBundle/translations/messages+intl-icu.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages+intl-icu.fr.yml new file mode 100644 index 000000000..ab3b963ab --- /dev/null +++ b/src/Bundle/ChillActivityBundle/translations/messages+intl-icu.fr.yml @@ -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} diff --git a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml index 3099e99b0..4ddad2292 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml @@ -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: diff --git a/src/Bundle/ChillBudgetBundle/Resources/views/Budget/_macros.html.twig b/src/Bundle/ChillBudgetBundle/Resources/views/Budget/_macros.html.twig index dfa286af4..a1fee19ce 100644 --- a/src/Bundle/ChillBudgetBundle/Resources/views/Budget/_macros.html.twig +++ b/src/Bundle/ChillBudgetBundle/Resources/views/Budget/_macros.html.twig @@ -1,11 +1,12 @@ -{% macro table_elements(elements, family) %} +{% macro table_elements(elements, type) %} + - - - - + + + + @@ -38,17 +39,17 @@ @@ -69,7 +70,7 @@
{{ 'Budget element type'|trans }}{{ 'Amount'|trans }}{{ 'Validity period'|trans }} {{ 'Budget element type'|trans }}{{ 'Amount'|trans }}{{ 'Validity period'|trans }} 
{% 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') }} + {% for result in results %} + + {{ result.label }} + + {% 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 %} + + + {% endfor %} {% endmacro %} diff --git a/src/Bundle/ChillBudgetBundle/Resources/views/Person/index.html.twig b/src/Bundle/ChillBudgetBundle/Resources/views/Person/index.html.twig index 18d04b889..aba564206 100644 --- a/src/Bundle/ChillBudgetBundle/Resources/views/Person/index.html.twig +++ b/src/Bundle/ChillBudgetBundle/Resources/views/Person/index.html.twig @@ -25,7 +25,7 @@

{{ 'Budget calculator'|trans }}

- {{ table_results(charges, resources) }} + {{ table_results(charges, resources, results) }}
{% if is_granted('CHILL_BUDGET_ELEMENT_CREATE', person) %} diff --git a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php index b90fb83d4..193b04934 100644 --- a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php +++ b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php @@ -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,83 +55,109 @@ 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 ($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)) { - $this->logger->debug(self::class . ' renew a subscription for', [ - 'userId' => $user->getId(), - 'username' => $user->getUsernameCanonical(), - ]); - - ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] - = $this->eventsOnUserSubscriptionCreator->renewSubscriptionForUser($user, $expiration); - $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); - - if (0 !== $expirationTs) { - ++$renewed; - } else { - $this->logger->warning(self::class . ' could not renew subscription for a user', [ - 'userId' => $user->getId(), - 'username' => $user->getUsernameCanonical(), - ]); - } - } - - if (!$this->mapCalendarToUser->hasActiveSubscription($user)) { - $this->logger->debug(self::class . ' create a subscription for', [ - 'userId' => $user->getId(), - 'username' => $user->getUsernameCanonical(), - ]); - - ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] - = $this->eventsOnUserSubscriptionCreator->createSubscriptionForUser($user, $expiration); - $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); - - if (0 !== $expirationTs) { - ++$created; - } else { - $this->logger->warning(self::class . ' could not create subscription for a user', [ - 'userId' => $user->getId(), - 'username' => $user->getUsernameCanonical(), - ]); - } - } - } - - ++$offset; + if (false === $u['enabled']) { + continue; } - $this->em->flush(); - $this->em->clear(); + $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; + } + + // 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)) { + $this->logger->debug(self::class . ' renew a subscription for', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + + ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] + = $this->eventsOnUserSubscriptionCreator->renewSubscriptionForUser($user, $expiration); + $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); + + if (0 !== $expirationTs) { + ++$renewed; + } else { + $this->logger->warning(self::class . ' could not renew subscription for a user', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + } + } + + if (!$this->mapCalendarToUser->hasActiveSubscription($user)) { + $this->logger->debug(self::class . ' create a subscription for', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + + ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] + = $this->eventsOnUserSubscriptionCreator->createSubscriptionForUser($user, $expiration); + $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); + + if (0 !== $expirationTs) { + ++$created; + } else { + $this->logger->warning(self::class . ' could not create subscription for a user', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + } + } + + + 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', diff --git a/src/Bundle/ChillCalendarBundle/Exception/UserAbsenceSyncException.php b/src/Bundle/ChillCalendarBundle/Exception/UserAbsenceSyncException.php new file mode 100644 index 000000000..a5e5a679a --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Exception/UserAbsenceSyncException.php @@ -0,0 +1,20 @@ +'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(); - } -} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReader.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReader.php new file mode 100644 index 000000000..c70072a47 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReader.php @@ -0,0 +1,69 @@ +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") + }; + } + +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderInterface.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderInterface.php new file mode 100644 index 000000000..a918bb7ea --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderInterface.php @@ -0,0 +1,22 @@ +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); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php index f56735de7..6a986c9db 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php @@ -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) { diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderTest.php new file mode 100644 index 000000000..089477fda --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderTest.php @@ -0,0 +1,176 @@ +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" + ]; + } + +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceSyncTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceSyncTest.php new file mode 100644 index 000000000..1b0f1e416 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceSyncTest.php @@ -0,0 +1,68 @@ +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"]; + } +} diff --git a/src/Bundle/ChillEventBundle/Controller/ParticipationController.php b/src/Bundle/ChillEventBundle/Controller/ParticipationController.php index 1929beac1..a2a82c88f 100644 --- a/src/Bundle/ChillEventBundle/Controller/ParticipationController.php +++ b/src/Bundle/ChillEventBundle/Controller/ParticipationController.php @@ -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, diff --git a/src/Bundle/ChillEventBundle/Entity/Event.php b/src/Bundle/ChillEventBundle/Entity/Event.php index d55a7b8a8..cc762e979 100644 --- a/src/Bundle/ChillEventBundle/Entity/Event.php +++ b/src/Bundle/ChillEventBundle/Entity/Event.php @@ -160,11 +160,11 @@ class Event implements HasCenterInterface, HasScopeInterface } /** - * @return ArrayIterator|Collection|Traversable + * @return Collection */ public function getParticipations() { - return $this->getParticipationsOrdered(); + return new ArrayCollection(iterator_to_array($this->getParticipationsOrdered())); } /** diff --git a/src/Bundle/ChillMainBundle/Export/ExportInterface.php b/src/Bundle/ChillMainBundle/Export/ExportInterface.php index f357a9fdb..a11a51746 100644 --- a/src/Bundle/ChillMainBundle/Export/ExportInterface.php +++ b/src/Bundle/ChillMainBundle/Export/ExportInterface.php @@ -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); diff --git a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php index 4e41a1740..51d1b3974 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php @@ -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 */ diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/forms.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/forms.scss index cd81f36dc..28c597bc0 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/forms.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/forms.scss @@ -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; } \ No newline at end of file diff --git a/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig b/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig index 9a9a11fbd..adf67b81b 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig @@ -1,110 +1,144 @@ {{ form_start(form) }} - {% set btnSubmit = 0 %} -
-
- {% if form.vars.has_search_box %} -
-
- {{ form_widget(form.q) }} - -
-
- {% endif %} -
- {% if form.dateRanges is defined %} - {% set btnSubmit = 1 %} - {% if form.dateRanges|length > 0 %} - {% for dateRangeName, _o in form.dateRanges %} -
- {% if form.dateRanges[dateRangeName].vars.label is not same as(false) %} - {{ form_label(form.dateRanges[dateRangeName])}} - {% else %} -
{{ 'activity_filter.By date'|trans }}
- {% endif %} -
-
- {{ 'chill_calendar.From'|trans }} - {{ form_widget(form.dateRanges[dateRangeName]['from']) }} - {{ 'chill_calendar.To'|trans }} - {{ form_widget(form.dateRanges[dateRangeName]['to']) }} +
+

+ +

+
+ {% set btnSubmit = 0 %} +
+
+ {% if form.vars.has_search_box %} +
+
+ {{ form_widget(form.q) }} + +
+
+ {% endif %} +
+ + {% if form.dateRanges is defined %} + {% set btnSubmit = 1 %} + {% if form.dateRanges|length > 0 %} + {% for dateRangeName, _o in form.dateRanges %} +
+ {% if form.dateRanges[dateRangeName].vars.label is not same as(false) %} + {{ form_label(form.dateRanges[dateRangeName])}} + {% else %} +
{{ 'filter_order.By date'|trans }}
+ {% endif %} +
+
+ {{ 'chill_calendar.From'|trans }} + {{ form_widget(form.dateRanges[dateRangeName]['from']) }} + {{ 'chill_calendar.To'|trans }} + {{ form_widget(form.dateRanges[dateRangeName]['to']) }} +
-
- {% endfor %} + {% endfor %} + {% endif %} {% endif %} - {% endif %} - {% if form.checkboxes is defined %} - {% set btnSubmit = 1 %} - {% if form.checkboxes|length > 0 %} - {% for checkbox_name, options in form.checkboxes %} -
-
{{ 'activity_filter.By'|trans }}
-
- {% for c in form['checkboxes'][checkbox_name].children %} - {{ form_widget(c) }} - {{ form_label(c) }} - {% endfor %} -
-
- {% endfor %} - {% endif %} - {% endif %} - {% if form.entity_choices is defined %} - {% set btnSubmit = 1 %} - {% if form.entity_choices |length > 0 %} - {% for checkbox_name, options in form.entity_choices %} -
- {% if form.entity_choices[checkbox_name].vars.label is not same as(false) %} - {{ form_label(form.entity_choices[checkbox_name])}} - {% endif %} -
- {% for c in form['entity_choices'][checkbox_name].children %} - {{ form_widget(c) }} - {{ form_label(c) }} - {% endfor %} -
-
- {% endfor %} - {% endif %} - {% endif %} - {% if form.user_pickers is defined %} - {% set btnSubmit = 1 %} - {% if form.user_pickers.children|length > 0 %} - {% for name, options in form.user_pickers %} -
- {% if form.user_pickers[name].vars.label is not same as(false) %} - {{ form_label(form.user_pickers[name]) }} - {% else %} - {{ form_label(form.user_pickers[name].vars.label) }} - {% endif %} -
- {{ form_widget(form.user_pickers[name]) }} -
-
- {% endfor %} - {% endif %} - {% endif %} - {% if form.single_checkboxes is defined %} - {% set btnSubmit = 1 %} - {% for name, _o in form.single_checkboxes %} + {% if form.checkboxes is defined %} + {% set btnSubmit = 1 %} + {% if form.checkboxes|length > 0 %} + {% for checkbox_name, options in form.checkboxes %} +
+
{{ 'filter_order.By'|trans }}
+
+ {% for c in form['checkboxes'][checkbox_name].children %} + {{ form_widget(c) }} + {{ form_label(c) }} + {% endfor %} +
+
+ {% endfor %} + {% endif %} + {% endif %} + + {% if form.entity_choices is defined %} + {% set btnSubmit = 1 %} + {% if form.entity_choices |length > 0 %} + {% for checkbox_name, options in form.entity_choices %} +
+ {% if form.entity_choices[checkbox_name].vars.label is not same as(false) %} + {{ form_label(form.entity_choices[checkbox_name])}} + {% endif %} +
+ {% for c in form['entity_choices'][checkbox_name].children %} + {{ form_widget(c) }} + {{ form_label(c) }} + {% endfor %} +
+
+ {% endfor %} + {% endif %} + {% endif %} + + {% if form.user_pickers is defined %} + {% set btnSubmit = 1 %} + {% if form.user_pickers.children|length > 0 %} + {% for name, options in form.user_pickers %} +
+ {% if form.user_pickers[name].vars.label is not same as(false) %} + {{ form_label(form.user_pickers[name]) }} + {% else %} + {{ form_label(form.user_pickers[name].vars.label) }} + {% endif %} +
+ {{ form_widget(form.user_pickers[name]) }} +
+
+ {% endfor %} + {% endif %} + {% endif %} + + {% if form.single_checkboxes is defined %} + {% set btnSubmit = 1 %} + {% for name, _o in form.single_checkboxes %} +
+
{{ 'filter_order.By'|trans }}
+
+ {{ form_widget(form.single_checkboxes[name]) }} +
+
+ {% endfor %} + {% endif %} + + {% if btnSubmit == 1 %}
-
{{ 'activity_filter.By'|trans }}
-
- {{ form_widget(form.single_checkboxes[name]) }} -
+
- {% endfor %} - {% endif %} + {% endif %} - {% if btnSubmit == 1 %} -
- -
- {% endif %} +
- {% for k,v in otherParameters %} - - {% endfor %} + {% if active|length > 0 %} +
+ {% for f in active %} + + {%- if f.label != '' %} + {{ f.label|trans }} : + {% endif -%} + {%- if f.position == 'search_box' and f.value is not null %} + {{ 'filter_order.search_box'|trans ~ ' :' }} + {% endif -%} + {{ f.value}}{# + #} + {% endfor %} +
+ {% endif %} +
+ +
+
+ +{% for k,v in otherParameters %} + +{% endfor %} {{ form_end(form) }} + diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelper.php b/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelper.php index 1105c9d8a..be884de94 100644 --- a/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelper.php +++ b/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelper.php @@ -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 { diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelperForCurrentUserInterface.php b/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelperForCurrentUserInterface.php index f0d3f9fba..54e30c244 100644 --- a/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelperForCurrentUserInterface.php +++ b/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelperForCurrentUserInterface.php @@ -25,7 +25,8 @@ interface AuthorizationHelperForCurrentUserInterface public function getReachableCenters(string $role, ?Scope $scope = null): array; /** - * @param array|Center|Center[] $center + * @param list
|Center $center + * @return list */ - public function getReachableScopes(string $role, $center): array; + public function getReachableScopes(string $role, array|Center $center): array; } diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelperInterface.php b/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelperInterface.php index 1176cf1fa..1dc9668ec 100644 --- a/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelperInterface.php +++ b/src/Bundle/ChillMainBundle/Security/Authorization/AuthorizationHelperInterface.php @@ -26,7 +26,8 @@ interface AuthorizationHelperInterface public function getReachableCenters(UserInterface $user, string $role, ?Scope $scope = null): array; /** - * @param Center|list
$center + * @param Center|array
$center + * @return list */ public function getReachableScopes(UserInterface $user, string $role, Center|array $center): array; } diff --git a/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverter.php b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverter.php index 026ff7a8a..72ad89c58 100644 --- a/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverter.php +++ b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverter.php @@ -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()); diff --git a/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverterInterface.php b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverterInterface.php index b20a5ced2..6c7d9a5bd 100644 --- a/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverterInterface.php +++ b/src/Bundle/ChillMainBundle/Service/RollingDate/RollingDateConverterInterface.php @@ -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; } diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderGetActiveFilterHelper.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderGetActiveFilterHelper.php new file mode 100644 index 000000000..5b36e52b3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderGetActiveFilterHelper.php @@ -0,0 +1,92 @@ + + */ + 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; + } +} diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php index 6a4d07167..5c5d164fc 100644 --- a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php @@ -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 + */ + 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 diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperBuilder.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperBuilder.php index d9a505dee..56c73871c 100644 --- a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperBuilder.php +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperBuilder.php @@ -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); diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperFactory.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperFactory.php index c88c71af5..6665750dd 100644 --- a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperFactory.php +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperFactory.php @@ -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; diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderPositionEnum.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderPositionEnum.php new file mode 100644 index 000000000..ed2337f1d --- /dev/null +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderPositionEnum.php @@ -0,0 +1,22 @@ +render($template, [ 'helper' => $helper, + 'active' => $this->filterOrderGetActiveFilterHelper->getActiveFilters($helper), 'form' => $helper->buildForm()->createView(), 'options' => $options, 'otherParameters' => $otherParameters, diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index 263a57049..96b2edd98 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -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 diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php index b32454387..8b4c0b27a 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodController.php @@ -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, diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodRegulationListController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodRegulationListController.php index 6bbb6c368..cd550ef59 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodRegulationListController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodRegulationListController.php @@ -78,6 +78,7 @@ class AccompanyingPeriodRegulationListController $form['jobs']->getData(), $form['services']->getData(), $form['locations']->getData(), + ['openingDate' => 'DESC', 'id' => 'DESC'], $paginator->getItemsPerPage(), $paginator->getCurrentPageFirstItemNumber() ); diff --git a/src/Bundle/ChillPersonBundle/Controller/ReassignAccompanyingPeriodController.php b/src/Bundle/ChillPersonBundle/Controller/ReassignAccompanyingPeriodController.php index bafc4b1cb..3e5b59c2a 100644 --- a/src/Bundle/ChillPersonBundle/Controller/ReassignAccompanyingPeriodController.php +++ b/src/Bundle/ChillPersonBundle/Controller/ReassignAccompanyingPeriodController.php @@ -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 diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 569dd1502..121bbba14 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -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, ], ], ]); diff --git a/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/JobWorkingOnCourseAggregator.php b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/JobWorkingOnCourseAggregator.php new file mode 100644 index 000000000..e93300e85 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/JobWorkingOnCourseAggregator.php @@ -0,0 +1,100 @@ +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; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ScopeWorkingOnCourseAggregator.php b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ScopeWorkingOnCourseAggregator.php new file mode 100644 index 000000000..b9f493af9 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/ScopeWorkingOnCourseAggregator.php @@ -0,0 +1,101 @@ +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; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/UserWorkingOnCourseAggregator.php b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/UserWorkingOnCourseAggregator.php new file mode 100644 index 000000000..b4941fa01 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Aggregator/AccompanyingCourseAggregators/UserWorkingOnCourseAggregator.php @@ -0,0 +1,100 @@ +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; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/CenterAggregator.php b/src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/CenterAggregator.php new file mode 100644 index 000000000..9be0b0c7e --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Aggregator/PersonAggregators/CenterAggregator.php @@ -0,0 +1,103 @@ +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; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Export/ListAccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Export/Export/ListAccompanyingPeriod.php index af66ab312..ab9c0db2f 100644 --- a/src/Bundle/ChillPersonBundle/Export/Export/ListAccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Export/Export/ListAccompanyingPeriod.php @@ -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); - } } diff --git a/src/Bundle/ChillPersonBundle/Export/Export/ListPersonWithAccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Export/Export/ListPersonHavingAccompanyingPeriod.php similarity index 95% rename from src/Bundle/ChillPersonBundle/Export/Export/ListPersonWithAccompanyingPeriod.php rename to src/Bundle/ChillPersonBundle/Export/Export/ListPersonHavingAccompanyingPeriod.php index 370046232..408d0b3af 100644 --- a/src/Bundle/ChillPersonBundle/Export/Export/ListPersonWithAccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Export/Export/ListPersonHavingAccompanyingPeriod.php @@ -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; } diff --git a/src/Bundle/ChillPersonBundle/Export/Export/ListPersonWithAccompanyingPeriodDetails.php b/src/Bundle/ChillPersonBundle/Export/Export/ListPersonWithAccompanyingPeriodDetails.php new file mode 100644 index 000000000..ddb16bb2d --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Export/ListPersonWithAccompanyingPeriodDetails.php @@ -0,0 +1,155 @@ +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]; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/JobWorkingOnCourseFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/JobWorkingOnCourseFilter.php new file mode 100644 index 000000000..63a668b6d --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/JobWorkingOnCourseFilter.php @@ -0,0 +1,130 @@ +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; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php index e9413083f..69fdd7bc0 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/OpenBetweenDatesFilter.php @@ -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); diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ScopeWorkingOnCourseFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ScopeWorkingOnCourseFilter.php new file mode 100644 index 000000000..b9787bf52 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/ScopeWorkingOnCourseFilter.php @@ -0,0 +1,132 @@ +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; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserWorkingOnCourseFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserWorkingOnCourseFilter.php index d078443af..1f9bfc61a 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserWorkingOnCourseFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/AccompanyingCourseFilters/UserWorkingOnCourseFilter.php @@ -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'])) ; } diff --git a/src/Bundle/ChillPersonBundle/Export/Helper/ListAccompanyingPeriodHelper.php b/src/Bundle/ChillPersonBundle/Export/Helper/ListAccompanyingPeriodHelper.php new file mode 100644 index 000000000..5fa2252cd --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Export/Helper/ListAccompanyingPeriodHelper.php @@ -0,0 +1,317 @@ +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); + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Helper/ListPersonHelper.php b/src/Bundle/ChillPersonBundle/Export/Helper/ListPersonHelper.php index 77a1d9c86..198794326 100644 --- a/src/Bundle/ChillPersonBundle/Export/Helper/ListPersonHelper.php +++ b/src/Bundle/ChillPersonBundle/Export/Helper/ListPersonHelper.php @@ -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[] $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 + */ + 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> $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; diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php index 0aaabd05f..79a7710ff 100644 --- a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php @@ -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 $scopesCanSee + * @param array $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, scopeCanSeeConfidential: list}> $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') + ); + + $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('scope_' . $key . '_' . $skey, $scope); + $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); } diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php index 0cca1a5f4..7b31887b9 100644 --- a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php @@ -31,28 +31,28 @@ interface AccompanyingPeriodACLAwareRepositoryInterface */ public function countByUserAndPostalCodesOpenedAccompanyingPeriod(?User $user, array $postalCodes): int; - public function countByUserOpenedAccompanyingPeriod(?User $user): int; - + /** + * @return array + */ public function findByPerson( Person $person, string $role, ?array $orderBy = [], - ?int $limit = null, - ?int $offset = null + ?int $limit = null, + ?int $offset = null ): array; /** * @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 */ - 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 */ 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; } diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/list_recent_by_accompanying_period.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/list_recent_by_accompanying_period.html.twig index 92b2e3920..897f19b46 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/list_recent_by_accompanying_period.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/list_recent_by_accompanying_period.html.twig @@ -1,9 +1,11 @@ - diff --git a/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php b/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php index 709624b54..795a921e7 100644 --- a/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php +++ b/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php @@ -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; } diff --git a/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php b/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php index 9d7f1cdd5..5b5773922 100644 --- a/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php +++ b/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php @@ -360,10 +360,12 @@ final class PersonContext implements PersonContextInterface private function isScopeNecessary(Person $person): bool { - if ($this->showScopes && 1 < $this->authorizationHelper->getReachableScopes( - $this->security->getUser(), - PersonDocumentVoter::CREATE, - $this->centerResolverManager->resolveCenters($person) + if ($this->showScopes && 1 < count( + $this->authorizationHelper->getReachableScopes( + $this->security->getUser(), + PersonDocumentVoter::CREATE, + $this->centerResolverManager->resolveCenters($person) + ) )) { return true; } diff --git a/src/Bundle/ChillPersonBundle/Tests/Repository/AccompanyingPeriodACLAwareRepositoryTest.php b/src/Bundle/ChillPersonBundle/Tests/Repository/AccompanyingPeriodACLAwareRepositoryTest.php new file mode 100644 index 000000000..cb8d56ba3 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Repository/AccompanyingPeriodACLAwareRepositoryTest.php @@ -0,0 +1,517 @@ +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, scopeCanSeeConfidential: list}> $centerScopes + * @param list $expectedContains + * @param list $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, scopeCanSeeConfidential: list}> $centerScopes + * @param list $expectedContains + * @param list $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 $scopeUserCanSee + * @param array $scopeUserCanSeeConfidential + * @param array $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 $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; + } +} diff --git a/src/Bundle/ChillPersonBundle/config/services/exports_accompanying_course.yaml b/src/Bundle/ChillPersonBundle/config/services/exports_accompanying_course.yaml index c299bbaaa..c5a2dba68 100644 --- a/src/Bundle/ChillPersonBundle/config/services/exports_accompanying_course.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/exports_accompanying_course.yaml @@ -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 } diff --git a/src/Bundle/ChillPersonBundle/config/services/exports_person.yaml b/src/Bundle/ChillPersonBundle/config/services/exports_person.yaml index bf372ea06..43f5556cf 100644 --- a/src/Bundle/ChillPersonBundle/config/services/exports_person.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/exports_person.yaml @@ -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 } + + diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index f783c403e..892bb0dfd 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -329,8 +329,9 @@ CHILL_PERSON_ACCOMPANYING_PERIOD_CREATE: Créer un parcours d'accompagnement CHILL_PERSON_ACCOMPANYING_PERIOD_UPDATE: Modifier un parcours d'accompagnement CHILL_PERSON_ACCOMPANYING_PERIOD_FULL: Voir les détails, créer, supprimer et mettre à jour un parcours d'accompagnement 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_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 ? diff --git a/src/Bundle/ChillTaskBundle/Controller/SingleTaskController.php b/src/Bundle/ChillTaskBundle/Controller/SingleTaskController.php index 22e49704f..3523c7a58 100644 --- a/src/Bundle/ChillTaskBundle/Controller/SingleTaskController.php +++ b/src/Bundle/ChillTaskBundle/Controller/SingleTaskController.php @@ -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(); } /** diff --git a/src/Bundle/ChillTaskBundle/Repository/SingleTaskStateRepository.php b/src/Bundle/ChillTaskBundle/Repository/SingleTaskStateRepository.php new file mode 100644 index 000000000..2d64c69a0 --- /dev/null +++ b/src/Bundle/ChillTaskBundle/Repository/SingleTaskStateRepository.php @@ -0,0 +1,47 @@ + + * @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; + } + +} diff --git a/src/Bundle/ChillTaskBundle/config/services/repositories.yaml b/src/Bundle/ChillTaskBundle/config/services/repositories.yaml index 7bee5abd0..9681e5d5c 100644 --- a/src/Bundle/ChillTaskBundle/config/services/repositories.yaml +++ b/src/Bundle/ChillTaskBundle/config/services/repositories.yaml @@ -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: ~ diff --git a/src/Bundle/ChillThirdPartyBundle/Search/ThirdPartyApiSearch.php b/src/Bundle/ChillThirdPartyBundle/Search/ThirdPartyApiSearch.php index 86c0fa9db..1d4e12074 100644 --- a/src/Bundle/ChillThirdPartyBundle/Search/ThirdPartyApiSearch.php +++ b/src/Bundle/ChillThirdPartyBundle/Search/ThirdPartyApiSearch.php @@ -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]; } }