mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 01:08:26 +00:00 
			
		
		
		
	Merge branch 'master' into user_filter_tasks
This commit is contained in:
		| @@ -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: "" | ||||
| @@ -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: "" | ||||
| @@ -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: "" | ||||
| @@ -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: "" | ||||
| @@ -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: "" | ||||
							
								
								
									
										36
									
								
								.changes/v2.4.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								.changes/v2.4.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| ## v2.4.0 - 2023-07-07 | ||||
|  | ||||
| ### Feature | ||||
| * ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] on "filter by user working" on accompanying period, add two dates to filters intervention within a period | ||||
| * ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] Add an aggregator by user's job working on a course | ||||
| * ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add an aggregator by user's scope working on a course | ||||
| * [export] on aggregator "user working on a course" | ||||
| * ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add a center aggregator for Person | ||||
| * ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] add a filter on "job working on a course" | ||||
| * ([#113](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/113)) [export] Add a filter on "scope working on a course" | ||||
| * ([#121](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/121)) Create a role "See Confidential Periods", separated from the "Reassign courses" role | ||||
| * ([#124](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/124)) Sync user absence / presence through microsoft outlook / graph api. | ||||
|  | ||||
| ### Fixed | ||||
| * ([#116](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/116)) On the accompanying course page, open the action on view mode if the user does not have right to update them (i.e. if the accompanying period is closed) | ||||
| * [export] Rename label for CurrentActionFilter (on accompanying period work) to make precision between "ouvert" and "sans date de fin" | ||||
| * Force the db to have either a person_location or a address_location, and avoid to have both also internally in the entity | ||||
| * [export] set rolling date on person age aggregator | ||||
| * [export] fix list when a person locating a course is without address | ||||
| * [export] remove unused condition on course about duration participation | ||||
| * Command to subscribe on MS Graph users calendars: improve the loop to be more efficient | ||||
|  | ||||
| ### DX | ||||
| * Rolling Date: can receive a null parameter | ||||
|  | ||||
| ### Traduction francophone des principaux changements | ||||
|  | ||||
| - sur le "filtre par intervenant", ajoute deux dates pour limiter la période d'intervention; | ||||
| - ajout d'un regroupement par métier des intervenants sur un parcours; | ||||
| - ajout d'un regroupement par service des intervenants sur un parcours; | ||||
| - ajout d'un regroupement par utilisateur intervenant sur un parcours | ||||
| - ajout d'un regroupement "par centre de l'usager"; | ||||
| - ajout d'un filtre "par métier intervenant sur un parcours"; | ||||
| - ajout d'un filtre "par service intervenant sur un parcours"; | ||||
| - création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot); | ||||
| - synchronisation de l'absence des utilisateurs par microsoft graph api | ||||
							
								
								
									
										37
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -6,6 +6,43 @@ 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. | ||||
|   | ||||
| @@ -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                                 | | ||||
|   | ||||
| @@ -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; | ||||
|             } | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -0,0 +1,20 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\CalendarBundle\Exception; | ||||
|  | ||||
| class UserAbsenceSyncException extends \LogicException | ||||
| { | ||||
|     public function __construct(string $message = "", int $code = 20_230_706, ?\Throwable $previous = null) | ||||
|     { | ||||
|         parent::__construct($message, $code, $previous); | ||||
|     } | ||||
| } | ||||
| @@ -1,84 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| /** | ||||
|  * Chill is a software for social workers. | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use DateInterval; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\Query\ResultSetMapping; | ||||
| use Doctrine\ORM\Query\ResultSetMappingBuilder; | ||||
| use function strtr; | ||||
|  | ||||
| /** | ||||
|  * Contains classes and methods for fetching users with some calendar metadatas. | ||||
|  */ | ||||
| class MSGraphUserRepository | ||||
| { | ||||
|     private const MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH = <<<'SQL' | ||||
|         select | ||||
|             {select} | ||||
|         from users u | ||||
|         where | ||||
|             NOT attributes ?? 'msgraph' | ||||
|             OR NOT attributes->'msgraph' ?? 'subscription_events_expiration' | ||||
|             OR (attributes->'msgraph' ?? 'subscription_events_expiration' AND (attributes->'msgraph'->>'subscription_events_expiration')::int < EXTRACT(EPOCH FROM (NOW() + :interval::interval))) | ||||
|         LIMIT :limit OFFSET :offset | ||||
|         ; | ||||
|         SQL; | ||||
|  | ||||
|     private EntityManagerInterface $entityManager; | ||||
|  | ||||
|     public function __construct(EntityManagerInterface $entityManager) | ||||
|     { | ||||
|         $this->entityManager = $entityManager; | ||||
|     } | ||||
|  | ||||
|     public function countByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval): int | ||||
|     { | ||||
|         $rsm = new ResultSetMapping(); | ||||
|         $rsm->addScalarResult('c', 'c'); | ||||
|  | ||||
|         $sql = strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, [ | ||||
|             '{select}' => 'COUNT(u) AS c', | ||||
|             'LIMIT :limit OFFSET :offset' => '', | ||||
|         ]); | ||||
|  | ||||
|         return $this->entityManager->createNativeQuery($sql, $rsm)->setParameters([ | ||||
|             'interval' => $interval, | ||||
|         ])->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return array|User[] | ||||
|      */ | ||||
|     public function findByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval, int $limit = 50, int $offset = 0): array | ||||
|     { | ||||
|         $rsm = new ResultSetMappingBuilder($this->entityManager); | ||||
|         $rsm->addRootEntityFromClassMetadata(User::class, 'u'); | ||||
|  | ||||
|         return $this->entityManager->createNativeQuery( | ||||
|             strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, ['{select}' => $rsm->generateSelectClause()]), | ||||
|             $rsm | ||||
|         )->setParameters([ | ||||
|             'interval' => $interval, | ||||
|             'limit' => $limit, | ||||
|             'offset' => $offset, | ||||
|         ])->getResult(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph; | ||||
|  | ||||
| use Chill\CalendarBundle\Exception\UserAbsenceSyncException; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; | ||||
| use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; | ||||
| use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; | ||||
| use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; | ||||
| use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; | ||||
| use Symfony\Contracts\HttpClient\HttpClientInterface; | ||||
|  | ||||
| final readonly class MSUserAbsenceReader implements MSUserAbsenceReaderInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private HttpClientInterface $machineHttpClient, | ||||
|         private MapCalendarToUser $mapCalendarToUser, | ||||
|         private ClockInterface $clock, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft | ||||
|      */ | ||||
|     public function isUserAbsent(User $user): bool|null | ||||
|     { | ||||
|         $id = $this->mapCalendarToUser->getUserId($user); | ||||
|  | ||||
|         if (null === $id) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             $automaticRepliesSettings = $this->machineHttpClient | ||||
|                 ->request('GET', 'users/' . $id . '/mailboxSettings/automaticRepliesSetting') | ||||
|                 ->toArray(true); | ||||
|         } catch (ClientExceptionInterface|DecodingExceptionInterface|RedirectionExceptionInterface|TransportExceptionInterface $e) { | ||||
|             throw new UserAbsenceSyncException("Error receiving response for mailboxSettings", 0, $e); | ||||
|         } catch (ServerExceptionInterface $e) { | ||||
|             throw new UserAbsenceSyncException("Server error receiving response for mailboxSettings", 0, $e); | ||||
|         } | ||||
|  | ||||
|         if (!array_key_exists("status", $automaticRepliesSettings)) { | ||||
|             throw new \LogicException("no key \"status\" on automatic replies settings: " . json_encode($automaticRepliesSettings, JSON_THROW_ON_ERROR)); | ||||
|         } | ||||
|  | ||||
|         return match ($automaticRepliesSettings['status']) { | ||||
|             'disabled' => false, | ||||
|             'alwaysEnabled' => true, | ||||
|             'scheduled' => | ||||
|                 RemoteEventConverter::convertStringDateWithoutTimezone($automaticRepliesSettings['scheduledStartDateTime']['dateTime']) < $this->clock->now() | ||||
|                 && RemoteEventConverter::convertStringDateWithoutTimezone($automaticRepliesSettings['scheduledEndDateTime']['dateTime']) > $this->clock->now(), | ||||
|             default => throw new UserAbsenceSyncException("this status is not documented by Microsoft") | ||||
|         }; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
|  | ||||
| interface MSUserAbsenceReaderInterface | ||||
| { | ||||
|     /** | ||||
|      * @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft | ||||
|      */ | ||||
|     public function isUserAbsent(User $user): bool|null; | ||||
| } | ||||
| @@ -0,0 +1,50 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
|  | ||||
| readonly class MSUserAbsenceSync | ||||
| { | ||||
|     public function __construct( | ||||
|         private MSUserAbsenceReaderInterface $absenceReader, | ||||
|         private ClockInterface $clock, | ||||
|         private LoggerInterface $logger, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function syncUserAbsence(User $user): void | ||||
|     { | ||||
|         $absence = $this->absenceReader->isUserAbsent($user); | ||||
|  | ||||
|         if (null === $absence) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if ($absence === $user->isAbsent()) { | ||||
|             // nothing to do | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $this->logger->info("will change user absence", ['userId' => $user->getId()]); | ||||
|  | ||||
|         if ($absence) { | ||||
|             $this->logger->debug("make user absent", ['userId' => $user->getId()]); | ||||
|             $user->setAbsenceStart($this->clock->now()); | ||||
|         } else { | ||||
|             $this->logger->debug("make user present", ['userId' => $user->getId()]); | ||||
|             $user->setAbsenceStart(null); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -0,0 +1,176 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\CalendarBundle\Tests\RemoteCalendar\Connector\MSGraph; | ||||
|  | ||||
| use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser; | ||||
| use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReader; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Component\Clock\MockClock; | ||||
| use Symfony\Component\HttpClient\MockHttpClient; | ||||
| use Symfony\Component\HttpClient\Response\MockResponse; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class MSUserAbsenceReaderTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataTestUserAbsence | ||||
|      */ | ||||
|     public function testUserAbsenceReader(string $mockResponse, bool $expected, string $message): void | ||||
|     { | ||||
|         $user = new User(); | ||||
|         $client = new MockHttpClient([new MockResponse($mockResponse)]); | ||||
|         $mapUser = $this->prophesize(MapCalendarToUser::class); | ||||
|         $mapUser->getUserId($user)->willReturn('1234'); | ||||
|         $clock = new MockClock(new \DateTimeImmutable('2023-07-07T12:00:00')); | ||||
|  | ||||
|         $absenceReader = new MSUserAbsenceReader($client, $mapUser->reveal(), $clock); | ||||
|  | ||||
|         self::assertEquals($expected, $absenceReader->isUserAbsent($user), $message); | ||||
|     } | ||||
|  | ||||
|     public function testIsUserAbsentWithoutRemoteId(): void | ||||
|     { | ||||
|         $user = new User(); | ||||
|         $client = new MockHttpClient(); | ||||
|  | ||||
|         $mapUser = $this->prophesize(MapCalendarToUser::class); | ||||
|         $mapUser->getUserId($user)->willReturn(null); | ||||
|         $clock = new MockClock(new \DateTimeImmutable('2023-07-07T12:00:00')); | ||||
|  | ||||
|         $absenceReader = new MSUserAbsenceReader($client, $mapUser->reveal(), $clock); | ||||
|  | ||||
|         self::assertNull($absenceReader->isUserAbsent($user), "when no user found, absence should be null"); | ||||
|     } | ||||
|  | ||||
|     public function provideDataTestUserAbsence(): iterable | ||||
|     { | ||||
|         // contains data that was retrieved from microsoft graph api on 2023-07-06 | ||||
|  | ||||
|         yield [ | ||||
|             <<<'JSON' | ||||
|             { | ||||
|               "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", | ||||
|               "status": "disabled", | ||||
|               "externalAudience": "none", | ||||
|               "internalReplyMessage": "Je suis en congé.", | ||||
|               "externalReplyMessage": "", | ||||
|               "scheduledStartDateTime": { | ||||
|                 "dateTime": "2023-07-06T12:00:00.0000000", | ||||
|                 "timeZone": "UTC" | ||||
|               }, | ||||
|               "scheduledEndDateTime": { | ||||
|                 "dateTime": "2023-07-07T12:00:00.0000000", | ||||
|                 "timeZone": "UTC" | ||||
|               } | ||||
|             } | ||||
|             JSON, | ||||
|             false, | ||||
|             "User is present" | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'JSON' | ||||
|                { | ||||
|                   "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", | ||||
|                   "status": "scheduled", | ||||
|                   "externalAudience": "none", | ||||
|                   "internalReplyMessage": "Je suis en congé.", | ||||
|                   "externalReplyMessage": "", | ||||
|                   "scheduledStartDateTime": { | ||||
|                     "dateTime": "2023-07-06T11:00:00.0000000", | ||||
|                     "timeZone": "UTC" | ||||
|                   }, | ||||
|                   "scheduledEndDateTime": { | ||||
|                     "dateTime": "2023-07-21T11:00:00.0000000", | ||||
|                     "timeZone": "UTC" | ||||
|                   } | ||||
|                 } | ||||
|             JSON, | ||||
|             true, | ||||
|             'User is absent with absence scheduled, we are within this period' | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'JSON' | ||||
|                { | ||||
|                   "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", | ||||
|                   "status": "scheduled", | ||||
|                   "externalAudience": "none", | ||||
|                   "internalReplyMessage": "Je suis en congé.", | ||||
|                   "externalReplyMessage": "", | ||||
|                   "scheduledStartDateTime": { | ||||
|                     "dateTime": "2023-07-08T11:00:00.0000000", | ||||
|                     "timeZone": "UTC" | ||||
|                   }, | ||||
|                   "scheduledEndDateTime": { | ||||
|                     "dateTime": "2023-07-21T11:00:00.0000000", | ||||
|                     "timeZone": "UTC" | ||||
|                   } | ||||
|                 } | ||||
|             JSON, | ||||
|             false, | ||||
|             'User is present: absence is scheduled for later' | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'JSON' | ||||
|                { | ||||
|                   "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", | ||||
|                   "status": "scheduled", | ||||
|                   "externalAudience": "none", | ||||
|                   "internalReplyMessage": "Je suis en congé.", | ||||
|                   "externalReplyMessage": "", | ||||
|                   "scheduledStartDateTime": { | ||||
|                     "dateTime": "2023-07-05T11:00:00.0000000", | ||||
|                     "timeZone": "UTC" | ||||
|                   }, | ||||
|                   "scheduledEndDateTime": { | ||||
|                     "dateTime": "2023-07-06T11:00:00.0000000", | ||||
|                     "timeZone": "UTC" | ||||
|                   } | ||||
|                 } | ||||
|             JSON, | ||||
|             false, | ||||
|             'User is present: absence is past' | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             <<<'JSON' | ||||
|                 { | ||||
|                   "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", | ||||
|                   "status": "alwaysEnabled", | ||||
|                   "externalAudience": "none", | ||||
|                   "internalReplyMessage": "Je suis en congé.", | ||||
|                   "externalReplyMessage": "", | ||||
|                   "scheduledStartDateTime": { | ||||
|                     "dateTime": "2023-07-06T12:00:00.0000000", | ||||
|                     "timeZone": "UTC" | ||||
|                   }, | ||||
|                   "scheduledEndDateTime": { | ||||
|                     "dateTime": "2023-07-07T12:00:00.0000000", | ||||
|                     "timeZone": "UTC" | ||||
|                   } | ||||
|                 } | ||||
|             JSON, | ||||
|             true, | ||||
|             "User is absent: absence is always enabled" | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\CalendarBundle\Tests\RemoteCalendar\Connector\MSGraph; | ||||
|  | ||||
| use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReader; | ||||
| use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReaderInterface; | ||||
| use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceSync; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Psr\Log\NullLogger; | ||||
| use Symfony\Component\Clock\MockClock; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class MSUserAbsenceSyncTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataTestSyncUserAbsence | ||||
|      */ | ||||
|     public function testSyncUserAbsence(User $user, ?bool $absenceFromMicrosoft, bool $expectedAbsence, ?\DateTimeImmutable $expectedAbsenceStart, string $message): void | ||||
|     { | ||||
|         $userAbsenceReader = $this->prophesize(MSUserAbsenceReaderInterface::class); | ||||
|         $userAbsenceReader->isUserAbsent($user)->willReturn($absenceFromMicrosoft); | ||||
|  | ||||
|         $clock = new MockClock(new \DateTimeImmutable('2023-07-01T12:00:00')); | ||||
|  | ||||
|         $syncer = new MSUserAbsenceSync($userAbsenceReader->reveal(), $clock, new NullLogger()); | ||||
|  | ||||
|         $syncer->syncUserAbsence($user); | ||||
|  | ||||
|         self::assertEquals($expectedAbsence, $user->isAbsent(), $message); | ||||
|         self::assertEquals($expectedAbsenceStart, $user->getAbsenceStart(), $message); | ||||
|     } | ||||
|  | ||||
|     public function provideDataTestSyncUserAbsence(): iterable | ||||
|     { | ||||
|         yield [new User(), false, false, null, "user present remains present"]; | ||||
|         yield [new User(), true, true, new \DateTimeImmutable('2023-07-01T12:00:00'), "user present becomes absent"]; | ||||
|  | ||||
|         $user = new User(); | ||||
|         $user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00")); | ||||
|         yield [$user, true, true, $abs, "user absent remains absent"]; | ||||
|  | ||||
|         $user = new User(); | ||||
|         $user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00")); | ||||
|         yield [$user, false, false, null, "user absent becomes present"]; | ||||
|  | ||||
|         yield [new User(), null, false, null, "user not syncable: presence do not change"]; | ||||
|  | ||||
|         $user = new User(); | ||||
|         $user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00")); | ||||
|         yield [$user, null, true, $abs, "user not syncable: absence do not change"]; | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
|   | ||||
| @@ -160,11 +160,11 @@ class Event implements HasCenterInterface, HasScopeInterface | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return ArrayIterator|Collection|Traversable | ||||
|      * @return Collection<Participation> | ||||
|      */ | ||||
|     public function getParticipations() | ||||
|     { | ||||
|         return $this->getParticipationsOrdered(); | ||||
|         return new ArrayCollection(iterator_to_array($this->getParticipationsOrdered())); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -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 | ||||
|     { | ||||
|   | ||||
| @@ -25,7 +25,8 @@ interface AuthorizationHelperForCurrentUserInterface | ||||
|     public function getReachableCenters(string $role, ?Scope $scope = null): array; | ||||
|  | ||||
|     /** | ||||
|      * @param array|Center|Center[] $center | ||||
|      * @param list<Center>|Center $center | ||||
|      * @return list<Scope> | ||||
|      */ | ||||
|     public function getReachableScopes(string $role, $center): array; | ||||
|     public function getReachableScopes(string $role, array|Center $center): array; | ||||
| } | ||||
|   | ||||
| @@ -26,7 +26,8 @@ interface AuthorizationHelperInterface | ||||
|     public function getReachableCenters(UserInterface $user, string $role, ?Scope $scope = null): array; | ||||
|  | ||||
|     /** | ||||
|      * @param Center|list<Center> $center | ||||
|      * @param Center|array<Center> $center | ||||
|      * @return list<Scope> | ||||
|      */ | ||||
|     public function getReachableScopes(UserInterface $user, string $role, Center|array $center): array; | ||||
| } | ||||
|   | ||||
| @@ -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()); | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -78,6 +78,7 @@ class AccompanyingPeriodRegulationListController | ||||
|                 $form['jobs']->getData(), | ||||
|                 $form['services']->getData(), | ||||
|                 $form['locations']->getData(), | ||||
|                 ['openingDate' => 'DESC', 'id' => 'DESC'], | ||||
|                 $paginator->getItemsPerPage(), | ||||
|                 $paginator->getCurrentPageFirstItemNumber() | ||||
|             ); | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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, | ||||
|                 ], | ||||
|             ], | ||||
|         ]); | ||||
|   | ||||
| @@ -0,0 +1,100 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators; | ||||
|  | ||||
| use Chill\MainBundle\Export\AggregatorInterface; | ||||
| use Chill\MainBundle\Repository\UserJobRepositoryInterface; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo; | ||||
| use Chill\PersonBundle\Export\Declarations; | ||||
| use Doctrine\ORM\Query\Expr\Join; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| final readonly class JobWorkingOnCourseAggregator implements AggregatorInterface | ||||
| { | ||||
|     private const COLUMN_NAME = 'user_working_on_course_job_id'; | ||||
|  | ||||
|     public function __construct( | ||||
|         private UserJobRepositoryInterface $userJobRepository, | ||||
|         private TranslatableStringHelperInterface $translatableStringHelper, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder) | ||||
|     { | ||||
|         // nothing to add here | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function getLabels($key, array $values, $data): \Closure | ||||
|     { | ||||
|         return function (int|string|null $jobId) { | ||||
|             if (null === $jobId || '' === $jobId) { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             if ('_header' === $jobId) { | ||||
|                 return 'export.aggregator.course.by_job_working.job'; | ||||
|             } | ||||
|  | ||||
|             if (null === $job = $this->userJobRepository->find((int) $jobId)) { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             return $this->translatableStringHelper->localize($job->getLabel()); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public function getQueryKeys($data) | ||||
|     { | ||||
|         return [self::COLUMN_NAME]; | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return 'export.aggregator.course.by_job_working.title'; | ||||
|     } | ||||
|  | ||||
|     public function addRole(): ?string | ||||
|     { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data) | ||||
|     { | ||||
|         if (!in_array('acpinfo', $qb->getAllAliases(), true)) { | ||||
|             $qb->leftJoin( | ||||
|                 AccompanyingPeriodInfo::class, | ||||
|                 'acpinfo', | ||||
|                 Join::WITH, | ||||
|                 'acp.id = IDENTITY(acpinfo.accompanyingPeriod)' | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) { | ||||
|             $qb->leftJoin('acpinfo.user', 'acpinfo_user'); | ||||
|         } | ||||
|  | ||||
|         $qb->addSelect('IDENTITY(acpinfo_user.userJob) AS ' . self::COLUMN_NAME); | ||||
|         $qb->addGroupBy(self::COLUMN_NAME); | ||||
|     } | ||||
|  | ||||
|     public function applyOn() | ||||
|     { | ||||
|         return Declarations::ACP_TYPE; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,101 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators; | ||||
|  | ||||
| use Chill\MainBundle\Export\AggregatorInterface; | ||||
| use Chill\MainBundle\Repository\ScopeRepositoryInterface; | ||||
| use Chill\MainBundle\Repository\UserJobRepositoryInterface; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo; | ||||
| use Chill\PersonBundle\Export\Declarations; | ||||
| use Doctrine\ORM\Query\Expr\Join; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| final readonly class ScopeWorkingOnCourseAggregator implements AggregatorInterface | ||||
| { | ||||
|     private const COLUMN_NAME = 'user_working_on_course_scope_id'; | ||||
|  | ||||
|     public function __construct( | ||||
|         private ScopeRepositoryInterface $scopeRepository, | ||||
|         private TranslatableStringHelperInterface $translatableStringHelper, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder) | ||||
|     { | ||||
|         // nothing to add here | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function getLabels($key, array $values, $data): \Closure | ||||
|     { | ||||
|         return function (int|string|null $scopeId) { | ||||
|             if (null === $scopeId || '' === $scopeId) { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             if ('_header' === $scopeId) { | ||||
|                 return 'export.aggregator.course.by_scope_working.scope'; | ||||
|             } | ||||
|  | ||||
|             if (null === $scope = $this->scopeRepository->find((int) $scopeId)) { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             return $this->translatableStringHelper->localize($scope->getName()); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public function getQueryKeys($data) | ||||
|     { | ||||
|         return [self::COLUMN_NAME]; | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return 'export.aggregator.course.by_scope_working.title'; | ||||
|     } | ||||
|  | ||||
|     public function addRole(): ?string | ||||
|     { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data) | ||||
|     { | ||||
|         if (!in_array('acpinfo', $qb->getAllAliases(), true)) { | ||||
|             $qb->leftJoin( | ||||
|                 AccompanyingPeriodInfo::class, | ||||
|                 'acpinfo', | ||||
|                 Join::WITH, | ||||
|                 'acp.id = IDENTITY(acpinfo.accompanyingPeriod)' | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) { | ||||
|             $qb->leftJoin('acpinfo.user', 'acpinfo_user'); | ||||
|         } | ||||
|  | ||||
|         $qb->addSelect('IDENTITY(acpinfo_user.mainScope) AS ' . self::COLUMN_NAME); | ||||
|         $qb->addGroupBy(self::COLUMN_NAME); | ||||
|     } | ||||
|  | ||||
|     public function applyOn() | ||||
|     { | ||||
|         return Declarations::ACP_TYPE; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,100 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators; | ||||
|  | ||||
| use Chill\MainBundle\Export\AggregatorInterface; | ||||
| use Chill\MainBundle\Repository\UserRepositoryInterface; | ||||
| use Chill\MainBundle\Templating\Entity\UserRender; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo; | ||||
| use Chill\PersonBundle\Export\Declarations; | ||||
| use Doctrine\ORM\Query\Expr\Join; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| final readonly class UserWorkingOnCourseAggregator implements AggregatorInterface | ||||
| { | ||||
|     private const COLUMN_NAME = 'user_working_on_course_user_id'; | ||||
|  | ||||
|     public function __construct( | ||||
|         private UserRender $userRender, | ||||
|         private UserRepositoryInterface $userRepository, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder) | ||||
|     { | ||||
|         // nothing to add here | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function getLabels($key, array $values, $data): \Closure | ||||
|     { | ||||
|         return function (int|string|null $userId) { | ||||
|             if (null === $userId || '' === $userId) { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             if ('_header' === $userId) { | ||||
|                 return 'export.aggregator.course.by_user_working.user'; | ||||
|             } | ||||
|  | ||||
|             if (null === $user = $this->userRepository->find((int) $userId)) { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             return $this->userRender->renderString($user, []); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public function getQueryKeys($data) | ||||
|     { | ||||
|         return [self::COLUMN_NAME]; | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return 'export.aggregator.course.by_user_working.title'; | ||||
|     } | ||||
|  | ||||
|     public function addRole(): ?string | ||||
|     { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data) | ||||
|     { | ||||
|         if (!in_array('acpinfo', $qb->getAllAliases(), true)) { | ||||
|             $qb->leftJoin( | ||||
|                 AccompanyingPeriodInfo::class, | ||||
|                 'acpinfo', | ||||
|                 Join::WITH, | ||||
|                 'acp.id = IDENTITY(acpinfo.accompanyingPeriod)' | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) { | ||||
|             $qb->leftJoin('acpinfo.user', 'acpinfo_user'); | ||||
|         } | ||||
|  | ||||
|         $qb->addSelect('acpinfo_user.id AS ' . self::COLUMN_NAME); | ||||
|         $qb->addGroupBy('acpinfo_user.id'); | ||||
|     } | ||||
|  | ||||
|     public function applyOn() | ||||
|     { | ||||
|         return Declarations::ACP_TYPE; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,103 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Export\Aggregator\PersonAggregators; | ||||
|  | ||||
| use Chill\MainBundle\Export\AggregatorInterface; | ||||
| use Chill\MainBundle\Form\Type\PickRollingDateType; | ||||
| use Chill\MainBundle\Repository\CenterRepositoryInterface; | ||||
| use Chill\MainBundle\Service\RollingDate\RollingDate; | ||||
| use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; | ||||
| use Chill\PersonBundle\Export\Declarations; | ||||
| use Closure; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| final readonly class CenterAggregator implements AggregatorInterface | ||||
| { | ||||
|     private const COLUMN_NAME = 'person_center_aggregator'; | ||||
|  | ||||
|     public function __construct( | ||||
|         private CenterRepositoryInterface $centerRepository, | ||||
|         private RollingDateConverterInterface $rollingDateConverter, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder) | ||||
|     { | ||||
|         $builder->add('at_date', PickRollingDateType::class, [ | ||||
|             'label' => 'export.aggregator.person.by_center.at_date', | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return [ | ||||
|             'at_date' => new RollingDate(RollingDate::T_TODAY) | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function getLabels($key, array $values, $data): Closure | ||||
|     { | ||||
|         return function (int|string|null $value) { | ||||
|             if (null === $value || '' === $value) { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             if ('_header' === $value) { | ||||
|                 return 'export.aggregator.person.by_center.center'; | ||||
|             } | ||||
|  | ||||
|             return (string) $this->centerRepository->find((int) $value)?->getName(); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public function getQueryKeys($data) | ||||
|     { | ||||
|         return [self::COLUMN_NAME]; | ||||
|     } | ||||
|  | ||||
|     public function getTitle() | ||||
|     { | ||||
|         return 'export.aggregator.person.by_center.title'; | ||||
|     } | ||||
|  | ||||
|     public function addRole(): ?string | ||||
|     { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data) | ||||
|     { | ||||
|         $alias = 'pers_center_agg'; | ||||
|         $atDate = 'pers_center_agg_at_date'; | ||||
|  | ||||
|         $qb->leftJoin('person.centerHistory', $alias); | ||||
|         $qb | ||||
|             ->andWhere( | ||||
|                 $qb->expr()->lte($alias.'.startDate', ':'.$atDate), | ||||
|             )->andWhere( | ||||
|                 $qb->expr()->orX( | ||||
|                     $qb->expr()->isNull($alias.'.endDate'), | ||||
|                     $qb->expr()->gt($alias.'.endDate', ':'.$atDate) | ||||
|                 ) | ||||
|             ); | ||||
|         $qb->setParameter($atDate, $this->rollingDateConverter->convert($data['at_date'])); | ||||
|  | ||||
|         $qb->addSelect("IDENTITY({$alias}.center) AS " . self::COLUMN_NAME); | ||||
|         $qb->addGroupBy(self::COLUMN_NAME); | ||||
|     } | ||||
|  | ||||
|     public function applyOn() | ||||
|     { | ||||
|         return Declarations::PERSON_TYPE; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,130 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\UserJob; | ||||
| use Chill\MainBundle\Export\FilterInterface; | ||||
| use Chill\MainBundle\Form\Type\PickRollingDateType; | ||||
| use Chill\MainBundle\Form\Type\PickUserDynamicType; | ||||
| use Chill\MainBundle\Repository\UserJobRepositoryInterface; | ||||
| use Chill\MainBundle\Service\RollingDate\RollingDate; | ||||
| use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; | ||||
| use Chill\MainBundle\Templating\Entity\UserRender; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Chill\PersonBundle\Export\Declarations; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Bridge\Doctrine\Form\Type\EntityType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| /** | ||||
|  * Filter course where a user with the given job is "working" on it | ||||
|  * | ||||
|  * Makes use of AccompanyingPeriodInfo | ||||
|  */ | ||||
| readonly class JobWorkingOnCourseFilter implements FilterInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private UserJobRepositoryInterface $userJobRepository, | ||||
|         private RollingDateConverterInterface $rollingDateConverter, | ||||
|         private TranslatableStringHelperInterface $translatableStringHelper, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder): void | ||||
|     { | ||||
|         $jobs = $this->userJobRepository->findAllActive(); | ||||
|         usort($jobs, fn (UserJob $a, UserJob $b) => $this->translatableStringHelper->localize($a->getLabel()) <=> $this->translatableStringHelper->localize($b->getLabel())); | ||||
|  | ||||
|         $builder | ||||
|             ->add('jobs', EntityType::class, [ | ||||
|                 'class' => UserJob::class, | ||||
|                 'choices' => $jobs, | ||||
|                 'choice_label' => fn (UserJob $userJob) => $this->translatableStringHelper->localize($userJob->getLabel()), | ||||
|                 'multiple' => true, | ||||
|                 'expanded' => true, | ||||
|             ]) | ||||
|             ->add('start_date', PickRollingDateType::class, [ | ||||
|                 'label' => 'export.filter.course.by_job_working.Job working after' | ||||
|             ]) | ||||
|             ->add('end_date', PickRollingDateType::class, [ | ||||
|                 'label' => 'export.filter.course.by_job_working.Job working before' | ||||
|             ]) | ||||
|         ; | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return [ | ||||
|             'jobs' => [], | ||||
|             'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START), | ||||
|             'end_date' => new RollingDate(RollingDate::T_TODAY), | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function getTitle(): string | ||||
|     { | ||||
|         return 'export.filter.course.by_job_working.title'; | ||||
|     } | ||||
|  | ||||
|     public function describeAction($data, $format = 'string'): array | ||||
|     { | ||||
|         return [ | ||||
|             'export.filter.course.by_job_working.Filtered by job working on course: only %jobs%, between %start_date% and %end_date%', [ | ||||
|                 '%jobs%' => implode( | ||||
|                     ', ', | ||||
|                     array_map( | ||||
|                         fn (UserJob $userJob) => $this->translatableStringHelper->localize($userJob->getLabel()), | ||||
|                         $data['jobs'] | ||||
|                     ) | ||||
|                 ), | ||||
|                 '%start_date%' => $this->rollingDateConverter->convert($data['start_date'])?->format('d-m-Y'), | ||||
|                 '%end_date%' => $this->rollingDateConverter->convert($data['end_date'])?->format('d-m-Y'), | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function addRole(): ?string | ||||
|     { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data): void | ||||
|     { | ||||
|         $ai_alias = 'jobs_working_on_course_filter_acc_info'; | ||||
|         $ai_user_alias = 'jobs_working_on_course_filter_user'; | ||||
|         $ai_jobs = 'jobs_working_on_course_filter_jobs'; | ||||
|         $start = 'acp_jobs_work_on_start'; | ||||
|         $end = 'acp_jobs_work_on_end'; | ||||
|  | ||||
|         $qb | ||||
|             ->andWhere( | ||||
|                 $qb->expr()->exists( | ||||
|                     "SELECT 1 FROM " . AccompanyingPeriod\AccompanyingPeriodInfo::class . " {$ai_alias} JOIN {$ai_alias}.user {$ai_user_alias} " . | ||||
|                     "WHERE IDENTITY({$ai_alias}.accompanyingPeriod) = acp.id | ||||
|                     AND {$ai_user_alias}.userJob IN (:{$ai_jobs}) | ||||
|                     AND {$ai_alias}.infoDate >= :{$start} and {$ai_alias}.infoDate < :{$end} | ||||
|                     " | ||||
|                 ) | ||||
|             ) | ||||
|             ->setParameter($ai_jobs, $data['jobs']) | ||||
|             ->setParameter($start, $this->rollingDateConverter->convert($data['start_date'])) | ||||
|             ->setParameter($end, $this->rollingDateConverter->convert($data['end_date'])) | ||||
|         ; | ||||
|     } | ||||
|  | ||||
|     public function applyOn(): string | ||||
|     { | ||||
|         return Declarations::ACP_TYPE; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|   | ||||
| @@ -0,0 +1,132 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\UserJob; | ||||
| use Chill\MainBundle\Export\FilterInterface; | ||||
| use Chill\MainBundle\Form\Type\PickRollingDateType; | ||||
| use Chill\MainBundle\Form\Type\PickUserDynamicType; | ||||
| use Chill\MainBundle\Repository\ScopeRepositoryInterface; | ||||
| use Chill\MainBundle\Repository\UserJobRepositoryInterface; | ||||
| use Chill\MainBundle\Service\RollingDate\RollingDate; | ||||
| use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface; | ||||
| use Chill\MainBundle\Templating\Entity\UserRender; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Chill\PersonBundle\Export\Declarations; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Bridge\Doctrine\Form\Type\EntityType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| /** | ||||
|  * Filter course where a user with the given scope is "working" on it | ||||
|  * | ||||
|  * Makes use of AccompanyingPeriodInfo | ||||
|  */ | ||||
| readonly class ScopeWorkingOnCourseFilter implements FilterInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private ScopeRepositoryInterface $scopeRepository, | ||||
|         private RollingDateConverterInterface $rollingDateConverter, | ||||
|         private TranslatableStringHelperInterface $translatableStringHelper, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder): void | ||||
|     { | ||||
|         $scopes = $this->scopeRepository->findAllActive(); | ||||
|         usort($scopes, fn (Scope $a, Scope $b) => $this->translatableStringHelper->localize($a->getName()) <=> $this->translatableStringHelper->localize($b->getName())); | ||||
|  | ||||
|         $builder | ||||
|             ->add('scopes', EntityType::class, [ | ||||
|                 'class' => Scope::class, | ||||
|                 'choices' => $scopes, | ||||
|                 'choice_label' => fn (Scope $scope) => $this->translatableStringHelper->localize($scope->getName()), | ||||
|                 'multiple' => true, | ||||
|                 'expanded' => true, | ||||
|             ]) | ||||
|             ->add('start_date', PickRollingDateType::class, [ | ||||
|                 'label' => 'export.filter.course.by_scope_working.Scope working after' | ||||
|             ]) | ||||
|             ->add('end_date', PickRollingDateType::class, [ | ||||
|                 'label' => 'export.filter.course.by_scope_working.Scope working before' | ||||
|             ]) | ||||
|         ; | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return [ | ||||
|             'scopes' => [], | ||||
|             'start_date' => new RollingDate(RollingDate::T_YEAR_CURRENT_START), | ||||
|             'end_date' => new RollingDate(RollingDate::T_TODAY), | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function getTitle(): string | ||||
|     { | ||||
|         return 'export.filter.course.by_scope_working.title'; | ||||
|     } | ||||
|  | ||||
|     public function describeAction($data, $format = 'string'): array | ||||
|     { | ||||
|         return [ | ||||
|             'export.filter.course.by_scope_working.Filtered by scope working on course: only %scopes%, between %start_date% and %end_date%', [ | ||||
|                 '%scopes%' => implode( | ||||
|                     ', ', | ||||
|                     array_map( | ||||
|                         fn (Scope $scope) => $this->translatableStringHelper->localize($scope->getName()), | ||||
|                         $data['scopes'] | ||||
|                     ) | ||||
|                 ), | ||||
|                 '%start_date%' => $this->rollingDateConverter->convert($data['start_date'])?->format('d-m-Y'), | ||||
|                 '%end_date%' => $this->rollingDateConverter->convert($data['end_date'])?->format('d-m-Y'), | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function addRole(): ?string | ||||
|     { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data): void | ||||
|     { | ||||
|         $ai_alias = 'scopes_working_on_course_filter_acc_info'; | ||||
|         $ai_user_alias = 'scopes_working_on_course_filter_user'; | ||||
|         $ai_scopes = 'scopes_working_on_course_filter_scopes'; | ||||
|         $start = 'acp_scopes_work_on_start'; | ||||
|         $end = 'acp_scopes_work_on_end'; | ||||
|  | ||||
|         $qb | ||||
|             ->andWhere( | ||||
|                 $qb->expr()->exists( | ||||
|                     "SELECT 1 FROM " . AccompanyingPeriod\AccompanyingPeriodInfo::class . " {$ai_alias} JOIN {$ai_alias}.user {$ai_user_alias} " . | ||||
|                     "WHERE IDENTITY({$ai_alias}.accompanyingPeriod) = acp.id | ||||
|                     AND {$ai_user_alias}.mainScope IN (:{$ai_scopes}) | ||||
|                     AND {$ai_alias}.infoDate >= :{$start} and {$ai_alias}.infoDate < :{$end} | ||||
|                     " | ||||
|                 ) | ||||
|             ) | ||||
|             ->setParameter($ai_scopes, $data['scopes']) | ||||
|             ->setParameter($start, $this->rollingDateConverter->convert($data['start_date'])) | ||||
|             ->setParameter($end, $this->rollingDateConverter->convert($data['end_date'])) | ||||
|         ; | ||||
|     } | ||||
|  | ||||
|     public function applyOn(): string | ||||
|     { | ||||
|         return Declarations::ACP_TYPE; | ||||
|     } | ||||
| } | ||||
| @@ -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'])) | ||||
|         ; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -12,107 +12,93 @@ declare(strict_types=1); | ||||
| namespace Chill\PersonBundle\Repository; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Address; | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Location; | ||||
| use Chill\MainBundle\Entity\PostalCode; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\UserJob; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelper; | ||||
| use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; | ||||
| use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; | ||||
| use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; | ||||
| use DateTime; | ||||
|  | ||||
| use DateTimeImmutable; | ||||
| use Doctrine\DBAL\Types\Types; | ||||
| use Doctrine\ORM\NonUniqueResultException; | ||||
| use Doctrine\ORM\NoResultException; | ||||
| use Doctrine\ORM\Query\Expr\Join; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Repository\AccompanyingPeriodACLAwareRepositoryTest; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use function count; | ||||
|  | ||||
| final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodACLAwareRepositoryInterface | ||||
| /** | ||||
|  * @see AccompanyingPeriodACLAwareRepositoryTest | ||||
|  */ | ||||
| final readonly class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodACLAwareRepositoryInterface | ||||
| { | ||||
|     private AccompanyingPeriodRepository $accompanyingPeriodRepository; | ||||
|  | ||||
|     private AuthorizationHelper $authorizationHelper; | ||||
|     private AuthorizationHelperForCurrentUserInterface $authorizationHelper; | ||||
|  | ||||
|     private CenterResolverDispatcherInterface $centerResolverDispatcher; | ||||
|     private CenterResolverManagerInterface $centerResolver; | ||||
|  | ||||
|     private Security $security; | ||||
|  | ||||
|     public function __construct( | ||||
|         AccompanyingPeriodRepository $accompanyingPeriodRepository, | ||||
|         Security $security, | ||||
|         AuthorizationHelper $authorizationHelper, | ||||
|         CenterResolverDispatcherInterface $centerResolverDispatcher | ||||
|         AuthorizationHelperForCurrentUserInterface $authorizationHelper, | ||||
|         CenterResolverManagerInterface $centerResolverDispatcher | ||||
|     ) { | ||||
|         $this->accompanyingPeriodRepository = $accompanyingPeriodRepository; | ||||
|         $this->security = $security; | ||||
|         $this->authorizationHelper = $authorizationHelper; | ||||
|         $this->centerResolverDispatcher = $centerResolverDispatcher; | ||||
|         $this->centerResolver = $centerResolverDispatcher; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param array|PostalCode[] | ||||
|      * | ||||
|      * @return QueryBuilder | ||||
|      */ | ||||
|     public function buildQueryOpenedAccompanyingCourseByUser(?User $user, array $postalCodes = []) | ||||
|     public function buildQueryOpenedAccompanyingCourseByUserAndPostalCodes(?User $user, array $postalCodes = []): QueryBuilder | ||||
|     { | ||||
|         $qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap'); | ||||
|  | ||||
|         $qb->where($qb->expr()->eq('ap.user', ':user')) | ||||
|             ->andWhere( | ||||
|                 $qb->expr()->neq('ap.step', ':draft'), | ||||
|                 $qb->expr()->orX( | ||||
|                     $qb->expr()->isNull('ap.closingDate'), | ||||
|                     $qb->expr()->gt('ap.closingDate', ':now') | ||||
|                 ) | ||||
|                 $qb->expr()->neq('ap.step', ':closed'), | ||||
|             ) | ||||
|             ->setParameter('user', $user) | ||||
|             ->setParameter('now', new DateTime('now')) | ||||
|             ->setParameter('draft', AccompanyingPeriod::STEP_DRAFT); | ||||
|             ->setParameter('draft', AccompanyingPeriod::STEP_DRAFT) | ||||
|             ->setParameter('closed', AccompanyingPeriod::STEP_CLOSED); | ||||
|  | ||||
|         if ([] !== $postalCodes) { | ||||
|             $qb->join('ap.locationHistories', 'location_history') | ||||
|                 ->leftJoin(PersonHouseholdAddress::class, 'person_address', Join::WITH, 'IDENTITY(location_history.personLocation) = IDENTITY(person_address.person)') | ||||
|             $qb->join('ap.locationHistories', 'location_history', Join::WITH, 'location_history.endDate IS NULL') | ||||
|                 ->leftJoin(Person\PersonCurrentAddress::class, 'person_address', Join::WITH, 'IDENTITY(location_history.personLocation) = IDENTITY(person_address.person)') | ||||
|                 ->join( | ||||
|                     Address::class, | ||||
|                     'address', | ||||
|                     Join::WITH, | ||||
|                     'COALESCE(IDENTITY(location_history.addressLocation), IDENTITY(person_address.address)) = address.id' | ||||
|                     'COALESCE(IDENTITY(person_address.address), IDENTITY(location_history.addressLocation)) = address.id' | ||||
|                 ) | ||||
|                 ->join('address.postcode', 'postcode') | ||||
|                 ->andWhere( | ||||
|                     $qb->expr()->orX( | ||||
|                         $qb->expr()->isNull('person_address'), | ||||
|                         $qb->expr()->andX( | ||||
|                             $qb->expr()->lte('person_address.validFrom', ':now'), | ||||
|                             $qb->expr()->orX( | ||||
|                                 $qb->expr()->isNull('person_address.validTo'), | ||||
|                                 $qb->expr()->lt('person_address.validTo', ':now') | ||||
|                             ) | ||||
|                         ) | ||||
|                     ) | ||||
|                     $qb->expr()->in('postcode.code', ':postal_codes') | ||||
|                 ) | ||||
|                 ->andWhere( | ||||
|                     $qb->expr()->isNull('location_history.endDate') | ||||
|                 ) | ||||
|                 ->andWhere( | ||||
|                     $qb->expr()->in('address.postcode', ':postal_codes') | ||||
|                 ) | ||||
|                 ->setParameter('now', new DateTimeImmutable('now'), Types::DATE_IMMUTABLE) | ||||
|                 ->setParameter('postal_codes', $postalCodes); | ||||
|                 ->setParameter('postal_codes', array_map(fn (PostalCode $postalCode) => $postalCode->getCode(), $postalCodes)); | ||||
|         } | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @throws NonUniqueResultException | ||||
|      * @throws NoResultException | ||||
|      */ | ||||
|     public function countByUnDispatched(array $jobs, array $services, array $administrativeLocations): int | ||||
|     { | ||||
|         $qb = $this->addACLByUnDispatched($this->buildQueryUnDispatched($jobs, $services, $administrativeLocations)); | ||||
|         $qb = $this->addACLMultiCenterOnQuery( | ||||
|             $this->buildQueryUnDispatched($jobs, $services, $administrativeLocations), | ||||
|             $this->buildCenterOnScope() | ||||
|         ); | ||||
|  | ||||
|         $qb->select('COUNT(ap)'); | ||||
|  | ||||
| @@ -125,22 +111,12 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         return $this->buildQueryOpenedAccompanyingCourseByUser($user, $postalCodes) | ||||
|             ->select('COUNT(ap)') | ||||
|             ->getQuery() | ||||
|             ->getSingleScalarResult(); | ||||
|     } | ||||
|         $qb = $this->buildQueryOpenedAccompanyingCourseByUserAndPostalCodes($user, $postalCodes); | ||||
|         $qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false); | ||||
|  | ||||
|     public function countByUserOpenedAccompanyingPeriod(?User $user): int | ||||
|     { | ||||
|         if (null === $user) { | ||||
|             return 0; | ||||
|         } | ||||
|         $qb->select('COUNT(DISTINCT ap)'); | ||||
|  | ||||
|         return $this->buildQueryOpenedAccompanyingCourseByUser($user) | ||||
|             ->select('COUNT(ap)') | ||||
|             ->getQuery() | ||||
|             ->getSingleScalarResult(); | ||||
|         return $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     public function findByPerson( | ||||
| @@ -152,10 +128,14 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC | ||||
|     ): array { | ||||
|         $qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap'); | ||||
|         $scopes = $this->authorizationHelper | ||||
|             ->getReachableCircles( | ||||
|                 $this->security->getUser(), | ||||
|             ->getReachableScopes( | ||||
|                 $role, | ||||
|                 $this->centerResolverDispatcher->resolveCenter($person) | ||||
|                 $this->centerResolver->resolveCenters($person) | ||||
|             ); | ||||
|         $scopesCanSeeConfidential = $this->authorizationHelper | ||||
|             ->getReachableScopes( | ||||
|                 AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, | ||||
|                 $this->centerResolver->resolveCenters($person) | ||||
|             ); | ||||
|  | ||||
|         if (0 === count($scopes)) { | ||||
| @@ -165,12 +145,44 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC | ||||
|         $qb | ||||
|             ->join('ap.participations', 'participation') | ||||
|             ->where($qb->expr()->eq('participation.person', ':person')) | ||||
|             ->andWhere( | ||||
|                 $qb->expr()->orX( | ||||
|                     'ap.confidential = FALSE', | ||||
|                     $qb->expr()->eq('ap.user', ':user') | ||||
|                 ) | ||||
|             ) | ||||
|             ->setParameter('person', $person); | ||||
|  | ||||
|         $qb = $this->addACLClauses($qb, $scopes, $scopesCanSeeConfidential); | ||||
|         $qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset); | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     public function addOrderLimitClauses(QueryBuilder $qb, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): QueryBuilder | ||||
|     { | ||||
|         if (null !== $orderBy) { | ||||
|             foreach ($orderBy as $field => $order) { | ||||
|                 $qb->addOrderBy('ap.' . $field, $order); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (null !== $limit) { | ||||
|             $qb->setMaxResults($limit); | ||||
|         } | ||||
|  | ||||
|         if (null !== $offset) { | ||||
|             $qb->setFirstResult($offset); | ||||
|         } | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add clause for scope on a query, based on no | ||||
|      * | ||||
|      * @param QueryBuilder $qb where the accompanying period have the `ap` alias | ||||
|      * @param array<Scope> $scopesCanSee | ||||
|      * @param array<Scope> $scopesCanSeeConfidential | ||||
|      * @return QueryBuilder | ||||
|      */ | ||||
|     public function addACLClauses(QueryBuilder $qb, array $scopesCanSee, array $scopesCanSeeConfidential): QueryBuilder | ||||
|     { | ||||
|         $qb | ||||
|             ->andWhere( | ||||
|                 $qb->expr()->orX( | ||||
|                     $qb->expr()->neq('ap.step', ':draft'), | ||||
| @@ -181,40 +193,67 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC | ||||
|                 ) | ||||
|             ) | ||||
|             ->setParameter('draft', AccompanyingPeriod::STEP_DRAFT) | ||||
|             ->setParameter('person', $person) | ||||
|             ->setParameter('user', $this->security->getUser()) | ||||
|             ->setParameter('creator', $this->security->getUser()); | ||||
|  | ||||
|         // add join condition for scopes | ||||
|         $orx = $qb->expr()->orX( | ||||
|             // even if the scope is not in one authorized, the user can see the course if it is in DRAFT state | ||||
|             $qb->expr()->eq('ap.step', ':draft') | ||||
|         ); | ||||
|  | ||||
|         foreach ($scopes as $key => $scope) { | ||||
|             $orx->add($qb->expr()->orX( | ||||
|         foreach ($scopesCanSee as $key => $scope) { | ||||
|             // for each scope: | ||||
|             // - either the user is the referrer of the course | ||||
|             // - or the accompanying course is one of the reachable scopes | ||||
|             // - and the parcours is not confidential OR the user is the referrer OR the user can see the confidential course | ||||
|  | ||||
|             $orOnScope = $qb->expr()->orX( | ||||
|                 $qb->expr()->isMemberOf(':scope_' . $key, 'ap.scopes'), | ||||
|                 $qb->expr()->eq('ap.user', ':user') | ||||
|             )); | ||||
|             ); | ||||
|  | ||||
|             if (in_array($scope, $scopesCanSeeConfidential, true)) { | ||||
|                 $orx->add($orOnScope); | ||||
|             } else { | ||||
|                 // we must add a condition: the course is not confidential or the user is the referrer | ||||
|                 $andXOnScope = $qb->expr()->andX( | ||||
|                     $orOnScope, | ||||
|                     $qb->expr()->orX( | ||||
|                         'ap.confidential = FALSE', | ||||
|                         $qb->expr()->eq('ap.user', ':user') | ||||
|                     ) | ||||
|                 ); | ||||
|                 $orx->add($andXOnScope); | ||||
|             } | ||||
|             $qb->setParameter('scope_' . $key, $scope); | ||||
|             $qb->setParameter('user', $this->security->getUser()); | ||||
|         } | ||||
|         $qb->andWhere($orx); | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|         return $qb; | ||||
|     } | ||||
|  | ||||
|     public function findByUnDispatched(array $jobs, array $services, array $administrativeLocations, ?int $limit = null, ?int $offset = null): array | ||||
|     public function buildCenterOnScope(): array | ||||
|     { | ||||
|         $qb = $this->addACLByUnDispatched($this->buildQueryUnDispatched($jobs, $services, $administrativeLocations)); | ||||
|         $centerOnScopes = []; | ||||
|         foreach ($this->authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE) as $center) { | ||||
|             $centerOnScopes[] = [ | ||||
|                 'center' => $center, | ||||
|                 'scopeOnRole' => $this->authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center), | ||||
|                 'scopeCanSeeConfidential' => $this->authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center), | ||||
|             ]; | ||||
|         } | ||||
|  | ||||
|         return $centerOnScopes; | ||||
|     } | ||||
|  | ||||
|     public function findByUnDispatched(array $jobs, array $services, array $administrativeAdministrativeLocations, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array | ||||
|     { | ||||
|         $qb = $this->buildQueryUnDispatched($jobs, $services, $administrativeAdministrativeLocations); | ||||
|         $qb->select('ap'); | ||||
|  | ||||
|         if (null !== $limit) { | ||||
|             $qb->setMaxResults($limit); | ||||
|         } | ||||
|  | ||||
|         if (null !== $offset) { | ||||
|             $qb->setFirstResult($offset); | ||||
|         } | ||||
|         $qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false); | ||||
|         $qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset); | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
| @@ -225,76 +264,80 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         $qb = $this->buildQueryOpenedAccompanyingCourseByUser($user); | ||||
|  | ||||
|         $qb->setFirstResult($offset) | ||||
|             ->setMaxResults($limit); | ||||
|  | ||||
|         foreach ($orderBy as $field => $direction) { | ||||
|             $qb->addOrderBy('ap.' . $field, $direction); | ||||
|         } | ||||
|         $qb = $this->buildQueryOpenedAccompanyingCourseByUserAndPostalCodes($user, $postalCodes); | ||||
|         $qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false); | ||||
|         $qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset); | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return array|AccompanyingPeriod[] | ||||
|      * @param QueryBuilder $qb | ||||
|      * @param list<array{center: Center, scopeOnRole: list<Scope>, scopeCanSeeConfidential: list<Scope>}> $centerScopes | ||||
|      * @param bool $allowNoCenter if true, will allow to see the periods linked to person which does not have any center. Very few edge case when some Person are not associated to a center. | ||||
|      * @return QueryBuilder | ||||
|      */ | ||||
|     public function findByUserOpenedAccompanyingPeriod(?User $user, array $orderBy = [], int $limit = 0, int $offset = 50): array | ||||
|     public function addACLMultiCenterOnQuery(QueryBuilder $qb, array $centerScopes, bool $allowNoCenter = false): QueryBuilder | ||||
|     { | ||||
|         if (null === $user) { | ||||
|             return []; | ||||
|         } | ||||
|         $user = $this->security->getUser(); | ||||
|  | ||||
|         $qb = $this->buildQueryOpenedAccompanyingCourseByUser($user); | ||||
|  | ||||
|         $qb->setFirstResult($offset) | ||||
|             ->setMaxResults($limit); | ||||
|  | ||||
|         foreach ($orderBy as $field => $direction) { | ||||
|             $qb->addOrderBy('ap.' . $field, $direction); | ||||
|         } | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     private function addACLByUnDispatched(QueryBuilder $qb): QueryBuilder | ||||
|     { | ||||
|         $centers = $this->authorizationHelper->getReachableCenters( | ||||
|             $this->security->getUser(), | ||||
|             AccompanyingPeriodVoter::SEE | ||||
|         ); | ||||
|  | ||||
|         $orX = $qb->expr()->orX(); | ||||
|  | ||||
|         if (0 === count($centers)) { | ||||
|         if (0 === count($centerScopes) || !$user instanceof User) { | ||||
|             return $qb->andWhere("'FALSE' = 'TRUE'"); | ||||
|         } | ||||
|  | ||||
|         foreach ($centers as $key => $center) { | ||||
|             $scopes = $this->authorizationHelper | ||||
|                 ->getReachableCircles( | ||||
|                     $this->security->getUser(), | ||||
|                     AccompanyingPeriodVoter::SEE, | ||||
|                     $center | ||||
|                 ); | ||||
|         $orX = $qb->expr()->orX(); | ||||
|  | ||||
|         $idx = 0; | ||||
|         foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) { | ||||
|             $and = $qb->expr()->andX( | ||||
|                 $qb->expr()->exists('SELECT part FROM ' . AccompanyingPeriodParticipation::class . ' part ' . | ||||
|                     "JOIN part.person p WHERE part.accompanyingPeriod = ap.id AND p.center = :center_{$key}") | ||||
|                 $qb->expr()->exists( | ||||
|                     'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . " part_{$idx} " . | ||||
|                     "JOIN part_{$idx}.person p{$idx} LEFT JOIN p{$idx}.centerCurrent centerCurrent_{$idx} " . | ||||
|                     "WHERE part_{$idx}.accompanyingPeriod = ap.id AND (centerCurrent_{$idx}.center = :center_{$idx}" | ||||
|                     . ($allowNoCenter ? " OR centerCurrent_{$idx}.id IS NULL)" : ")") | ||||
|                 ) | ||||
|             ); | ||||
|             $qb->setParameter('center_' . $key, $center); | ||||
|             $orScope = $qb->expr()->orX(); | ||||
|             $qb->setParameter('center_' . $idx, $center); | ||||
|  | ||||
|             foreach ($scopes as $skey => $scope) { | ||||
|                 $orScope->add( | ||||
|                     $qb->expr()->isMemberOf(':scope_' . $key . '_' . $skey, 'ap.scopes') | ||||
|             $orScopeInsideCenter = $qb->expr()->orX( | ||||
|                 // even if the scope is not in one authorized, the user can see the course if it is in DRAFT state | ||||
|                 $qb->expr()->eq('ap.step', ':draft') | ||||
|             ); | ||||
|  | ||||
|             $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); | ||||
|         } | ||||
|   | ||||
| @@ -31,28 +31,28 @@ interface AccompanyingPeriodACLAwareRepositoryInterface | ||||
|      */ | ||||
|     public function countByUserAndPostalCodesOpenedAccompanyingPeriod(?User $user, array $postalCodes): int; | ||||
|  | ||||
|     public function countByUserOpenedAccompanyingPeriod(?User $user): int; | ||||
|  | ||||
|     /** | ||||
|      * @return array<AccompanyingPeriod> | ||||
|      */ | ||||
|     public function findByPerson( | ||||
|         Person $person, | ||||
|         string $role, | ||||
|         ?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<AccompanyingPeriod> | ||||
|      */ | ||||
|     public function findByUnDispatched(array $jobs, array $services, array $administrativeLocations, ?int $limit = null, ?int $offset = null): array; | ||||
|     public function findByUnDispatched(array $jobs, array $services, array $administrativeAdministrativeLocations, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; | ||||
|  | ||||
|     /** | ||||
|      * @param array|PostalCode[] $postalCodes | ||||
|      * @return list<AccompanyingPeriod> | ||||
|      */ | ||||
|     public function findByUserAndPostalCodesOpenedAccompanyingPeriod(?User $user, array $postalCodes, array $orderBy = [], int $limit = 0, int $offset = 50): array; | ||||
|  | ||||
|     public function findByUserOpenedAccompanyingPeriod(?User $user, array $orderBy = [], int $limit = 0, int $offset = 50): array; | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
|   <div class="accompanying-course-work"> | ||||
| <div class="accompanying-course-work"> | ||||
|     {% for w in works | slice(0,5) %} | ||||
|  | ||||
|     <a href="{{ chill_path_add_return_path('chill_person_accompanying_period_work_edit', { 'id': w.id }) }}" | ||||
|        class="dashboard-link" title="{{ 'crud.social_action.title_link'|trans }}"> | ||||
|  | ||||
|         <a href="{%- if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', w) -%} | ||||
|             {{- chill_path_add_return_path('chill_person_accompanying_period_work_edit', { 'id': w.id }) -}} | ||||
|         {%- elseif is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_SEE', w) -%} | ||||
|             {{- chill_path_add_return_path('chill_person_accompanying_period_work_show', { 'id': w.id }) -}} | ||||
|         {%- else %}#{% endif -%}" class="dashboard-link" title="{{ 'crud.social_action.title_link'|trans }}"> | ||||
|         <div class="dashboard"> | ||||
|             <span class="title_label"></span> | ||||
|             <span class="title_action"><span class="like-h3">{{ w.socialAction|chill_entity_render_string }}</span> | ||||
| @@ -14,7 +16,7 @@ | ||||
|                         <b>{{ w.startDate|format_date('short') }}</b> | ||||
|                     </li> | ||||
|                     {% if w.endDate %} | ||||
|                     <li> | ||||
|                         <li> | ||||
|                         <span class="item-key">{{ 'accompanying_course_work.end_date'|trans ~ ' : ' }}</span> | ||||
|                         <b>{{ w.endDate|format_date('short') }}</b> | ||||
|                     </li> | ||||
| @@ -23,20 +25,20 @@ | ||||
|  | ||||
|                 <ul class="small_in_title"> | ||||
|                     {% if w.handlingThierParty %} | ||||
|                     <li> | ||||
|                         <li> | ||||
|                         <span class="item-key">{{ 'Thirdparty handling'|trans ~ ' : ' }}</span> | ||||
|                         <span class="badge-thirdparty">{{ w.handlingThierParty|chill_entity_render_box }}</span> | ||||
|                     </li> | ||||
|                     {% endif %} | ||||
|                     {% if w.referrers %} | ||||
|                     <li> | ||||
|                         <li> | ||||
|                         <span class="item-key">{{ 'Referrers'|trans ~ ' : ' }}</span> | ||||
|                         {% for u in w.referrers %} | ||||
|                             <span class="badge-user">{{ u|chill_entity_render_box }}</span> | ||||
|                         {% endfor %} | ||||
|                         {% if w.referrers|length == 0 %} | ||||
|                             <span class="chill-no-data-statement">{{ 'Not given'|trans }}</span> | ||||
|                         {% endif %} | ||||
|                             {% if w.referrers|length == 0 %} | ||||
|                                 <span class="chill-no-data-statement">{{ 'Not given'|trans }}</span> | ||||
|                             {% endif %} | ||||
|                     </li> | ||||
|                     {% endif %} | ||||
|                     <li class="associated-persons"> | ||||
| @@ -65,8 +67,8 @@ | ||||
|             </span> | ||||
|         </div> | ||||
|  | ||||
|     </a> | ||||
|         </a> | ||||
|     {% endfor %} | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|                 } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,517 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Repository; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Repository\CenterRepositoryInterface; | ||||
| use Chill\MainBundle\Repository\ScopeRepositoryInterface; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; | ||||
| use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepository; | ||||
| use Chill\PersonBundle\Repository\AccompanyingPeriodRepository; | ||||
| use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Prophecy\Argument; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     private AccompanyingPeriodRepository $accompanyingPeriodRepository; | ||||
|  | ||||
|     private CenterResolverManagerInterface $centerResolverManager; | ||||
|  | ||||
|     private CenterRepositoryInterface $centerRepository; | ||||
|  | ||||
|     private EntityManagerInterface $entityManager; | ||||
|  | ||||
|     private ScopeRepositoryInterface $scopeRepository; | ||||
|  | ||||
|     private Registry $registry; | ||||
|  | ||||
|     private static array $periodsIdsToDelete = []; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|         $this->accompanyingPeriodRepository = self::$container->get(AccompanyingPeriodRepository::class); | ||||
|         $this->centerRepository = self::$container->get(CenterRepositoryInterface::class); | ||||
|         $this->centerResolverManager = self::$container->get(CenterResolverManagerInterface::class); | ||||
|         $this->entityManager = self::$container->get(EntityManagerInterface::class); | ||||
|         $this->scopeRepository = self::$container->get(ScopeRepositoryInterface::class); | ||||
|         $this->registry = self::$container->get(Registry::class); | ||||
|     } | ||||
|  | ||||
|     public static function tearDownAfterClass(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|         $em = self::$container->get(EntityManagerInterface::class); | ||||
|         $repository = self::$container->get(AccompanyingPeriodRepository::class); | ||||
|  | ||||
|         foreach (self::$periodsIdsToDelete as $id) { | ||||
|             if (null === $period = $repository->find($id)) { | ||||
|                 throw new \RuntimeException("period not found while trying to delete it"); | ||||
|             } | ||||
|  | ||||
|             foreach ($period->getParticipations() as $participation) { | ||||
|                 $em->remove($participation); | ||||
|             } | ||||
|             $em->remove($period); | ||||
|         } | ||||
|  | ||||
|         //$em->flush(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataFindByUserAndPostalCodesOpenedAccompanyingPeriod | ||||
|      * @param list<array{center: Center, scopeOnRole: list<Scope>, scopeCanSeeConfidential: list<Scope>}> $centerScopes | ||||
|      * @param list<AccompanyingPeriod> $expectedContains | ||||
|      * @param list<AccompanyingPeriod> $expectedNotContains | ||||
|      */ | ||||
|     public function testFindByUserAndPostalCodesOpenedAccompanyingPeriod(User $user, User $searched, array $centerScopes, array $expectedContains, array $expectedNotContains, string $message): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->getUser()->willReturn($user); | ||||
|  | ||||
|         $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); | ||||
|         $centers = []; | ||||
|  | ||||
|         foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) { | ||||
|             $centers[spl_object_hash($center)] = $center; | ||||
|             $authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center) | ||||
|                 ->willReturn($scopes); | ||||
|             $authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center) | ||||
|                 ->willReturn($scopesCanSeeConfidential); | ||||
|         } | ||||
|         $authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE)->willReturn(array_values($centers)); | ||||
|  | ||||
|         $repository = new AccompanyingPeriodACLAwareRepository( | ||||
|             $this->accompanyingPeriodRepository, | ||||
|             $security->reveal(), | ||||
|             $authorizationHelper->reveal(), | ||||
|             $this->centerResolverManager | ||||
|         ); | ||||
|  | ||||
|         $actual = array_map( | ||||
|             fn (AccompanyingPeriod $period) => $period->getId(), | ||||
|             $repository->findByUserAndPostalCodesOpenedAccompanyingPeriod($searched, [], ['id' => 'DESC'], 20, 0) | ||||
|         ); | ||||
|  | ||||
|         foreach ($expectedContains as $expected) { | ||||
|             self::assertContains($expected->getId(), $actual, $message); | ||||
|         } | ||||
|         foreach ($expectedNotContains as $expected) { | ||||
|             self::assertNotContains($expected->getId(), $actual, $message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function provideDataFindByUserAndPostalCodesOpenedAccompanyingPeriod(): iterable | ||||
|     { | ||||
|         $this->setUp(); | ||||
|  | ||||
|         if (null === $user = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u")->setMaxResults(1)->getSingleResult()) { | ||||
|             throw new \RuntimeException("no user found"); | ||||
|         } | ||||
|  | ||||
|         if (null === $anotherUser = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u WHERE u.id != :uid")->setParameter('uid', $user->getId()) | ||||
|             ->setMaxResults(1)->getSingleResult()) { | ||||
|             throw new \RuntimeException("no user found"); | ||||
|         } | ||||
|  | ||||
|         /** @var Person $person */ | ||||
|         [$person, $anotherPerson, $person2, $person3] = $this->entityManager | ||||
|             ->createQuery("SELECT p FROM " . Person::class . " p JOIN p.centerCurrent current_center") | ||||
|             ->setMaxResults(4) | ||||
|             ->getResult(); | ||||
|  | ||||
|         if (null === $person || null === $anotherPerson || null === $person2 || null === $person3) { | ||||
|             throw new \RuntimeException("no person found"); | ||||
|         } | ||||
|  | ||||
|         $scopes = $this->scopeRepository->findAll(); | ||||
|  | ||||
|         if (3 > count($scopes)) { | ||||
|             throw new \RuntimeException("not enough scopes for this test"); | ||||
|         } | ||||
|         $scopesCanSee = [ $scopes[0] ]; | ||||
|         $scopesGroup2 = [ $scopes[1] ]; | ||||
|  | ||||
|         $centers = $this->centerRepository->findActive(); | ||||
|         $aCenterNotAssociatedToPerson = array_values(array_filter($centers, fn (Center $c) => $c !== $person->getCenter()))[0]; | ||||
|  | ||||
|         if (2 > count($centers)) { | ||||
|             throw new \RuntimeException("not enough centers for this test"); | ||||
|         } | ||||
|  | ||||
|         $period = $this->buildPeriod($person, $scopesCanSee, $user, true); | ||||
|         $period->setUser($user); | ||||
|  | ||||
|         yield [ | ||||
|             $anotherUser, | ||||
|             $user, | ||||
|             [ | ||||
|                 [ | ||||
|                     'center' => $person->getCenter(), | ||||
|                     'scopeOnRole' => $scopesCanSee, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|             ], | ||||
|             [$period], | ||||
|             [], | ||||
|             "period should be visible with expected scopes", | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $anotherUser, | ||||
|             $user, | ||||
|             [ | ||||
|                 [ | ||||
|                     'center' => $person->getCenter(), | ||||
|                     'scopeOnRole' => $scopesGroup2, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|             ], | ||||
|             [], | ||||
|             [$period], | ||||
|             "period should not be visible without expected scopes", | ||||
|         ]; | ||||
|  | ||||
|         yield  [ | ||||
|             $anotherUser, | ||||
|             $user, | ||||
|             [ | ||||
|                 [ | ||||
|                     'center' => $person->getCenter(), | ||||
|                     'scopeOnRole' => $scopesGroup2, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|                 [ | ||||
|                     'center' => $aCenterNotAssociatedToPerson, | ||||
|                     'scopeOnRole' => $scopesCanSee, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|             ], | ||||
|             [], | ||||
|             [$period], | ||||
|             "period should not be visible for user having right in another scope (with multiple centers)" | ||||
|         ]; | ||||
|  | ||||
|         $period = $this->buildPeriod($person, $scopesCanSee, $user, true); | ||||
|         $period->setUser($user); | ||||
|         $period->setConfidential(true); | ||||
|  | ||||
|         yield [ | ||||
|             $anotherUser, | ||||
|             $user, | ||||
|             [ | ||||
|                 [ | ||||
|                     'center' => $person->getCenter(), | ||||
|                     'scopeOnRole' => $scopesCanSee, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|             ], | ||||
|             [], | ||||
|             [$period], | ||||
|             "period confidential should not be visible", | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $anotherUser, | ||||
|             $user, | ||||
|             [ | ||||
|                 [ | ||||
|                     'center' => $person->getCenter(), | ||||
|                     'scopeOnRole' => $scopesCanSee, | ||||
|                     'scopeCanSeeConfidential' => $scopesCanSee, | ||||
|                 ], | ||||
|             ], | ||||
|             [$period], | ||||
|             [], | ||||
|             "period confidential be visible if user has required scopes", | ||||
|         ]; | ||||
|  | ||||
|         $this->entityManager->flush(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataFindByUndispatched | ||||
|      * @param list<array{center: Center, scopeOnRole: list<Scope>, scopeCanSeeConfidential: list<Scope>}> $centerScopes | ||||
|      * @param list<AccompanyingPeriod> $expectedContains | ||||
|      * @param list<AccompanyingPeriod> $expectedNotContains | ||||
|      */ | ||||
|     public function testFindByUndispatched(User $user, array $centerScopes, array $expectedContains, array $expectedNotContains, string $message): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->getUser()->willReturn($user); | ||||
|  | ||||
|         $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); | ||||
|         $centers = []; | ||||
|  | ||||
|         foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) { | ||||
|             $centers[spl_object_hash($center)] = $center; | ||||
|             $authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center) | ||||
|                 ->willReturn($scopes); | ||||
|             $authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center) | ||||
|                 ->willReturn($scopesCanSeeConfidential); | ||||
|         } | ||||
|         $authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE)->willReturn(array_values($centers)); | ||||
|  | ||||
|         $repository = new AccompanyingPeriodACLAwareRepository( | ||||
|             $this->accompanyingPeriodRepository, | ||||
|             $security->reveal(), | ||||
|             $authorizationHelper->reveal(), | ||||
|             $this->centerResolverManager | ||||
|         ); | ||||
|  | ||||
|         $actual = array_map( | ||||
|             fn (AccompanyingPeriod $period) => $period->getId(), | ||||
|             $repository->findByUnDispatched([], [], [], ['id' => 'DESC'], 20, 0) | ||||
|         ); | ||||
|  | ||||
|         foreach ($expectedContains as $expected) { | ||||
|             self::assertContains($expected->getId(), $actual, $message); | ||||
|         } | ||||
|         foreach ($expectedNotContains as $expected) { | ||||
|             self::assertNotContains($expected->getId(), $actual, $message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function provideDataFindByUndispatched(): iterable | ||||
|     { | ||||
|         $this->setUp(); | ||||
|  | ||||
|         if (null === $user = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u")->setMaxResults(1)->getSingleResult()) { | ||||
|             throw new \RuntimeException("no user found"); | ||||
|         } | ||||
|  | ||||
|         if (null === $anotherUser = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u WHERE u.id != :uid")->setParameter('uid', $user->getId()) | ||||
|             ->setMaxResults(1)->getSingleResult()) { | ||||
|             throw new \RuntimeException("no user found"); | ||||
|         } | ||||
|  | ||||
|         /** @var Person $person */ | ||||
|         [$person, $anotherPerson, $person2, $person3] = $this->entityManager | ||||
|             ->createQuery("SELECT p FROM " . Person::class . " p ") | ||||
|             ->setMaxResults(4) | ||||
|             ->getResult(); | ||||
|  | ||||
|         if (null === $person || null === $anotherPerson || null === $person2 || null === $person3) { | ||||
|             throw new \RuntimeException("no person found"); | ||||
|         } | ||||
|  | ||||
|         $scopes = $this->scopeRepository->findAll(); | ||||
|  | ||||
|         if (3 > count($scopes)) { | ||||
|             throw new \RuntimeException("not enough scopes for this test"); | ||||
|         } | ||||
|         $scopesCanSee = [ $scopes[0] ]; | ||||
|         $scopesGroup2 = [ $scopes[1] ]; | ||||
|  | ||||
|         $centers = $this->centerRepository->findActive(); | ||||
|  | ||||
|         if (2 > count($centers)) { | ||||
|             throw new \RuntimeException("not enough centers for this test"); | ||||
|         } | ||||
|  | ||||
|         $period = $this->buildPeriod($person, $scopesCanSee, $user, true); | ||||
|  | ||||
|  | ||||
|         // expected scope: can see the period | ||||
|         yield [ | ||||
|             $anotherUser, | ||||
|             [ | ||||
|                 [ | ||||
|                     'center' => $person->getCenter(), | ||||
|                     'scopeOnRole' => $scopesCanSee, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|             ], | ||||
|             [$period], | ||||
|             [], | ||||
|             "period should be visible with expected scopes", | ||||
|         ]; | ||||
|  | ||||
|         // no scope visible | ||||
|         yield [ | ||||
|             $anotherUser, | ||||
|             [ | ||||
|                 [ | ||||
|                     'center' => $person->getCenter(), | ||||
|                     'scopeOnRole' => $scopesGroup2, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|             ], | ||||
|             [], | ||||
|             [$period], | ||||
|             "period should not be visible without expected scopes", | ||||
|         ]; | ||||
|  | ||||
|         // another center | ||||
|         yield  [ | ||||
|             $anotherUser, | ||||
|             [ | ||||
|                 [ | ||||
|                     'center' => $person->getCenter(), | ||||
|                     'scopeOnRole' => $scopesGroup2, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|                 [ | ||||
|                     'center' => array_values(array_filter($centers, fn (Center $c) => $c !== $person->getCenter()))[0], | ||||
|                     'scopeOnRole' => $scopesCanSee, | ||||
|                     'scopeCanSeeConfidential' => [], | ||||
|                 ], | ||||
|             ], | ||||
|             [], | ||||
|             [$period], | ||||
|             "period should not be visible for user having right in another scope (with multiple centers)" | ||||
|         ]; | ||||
|  | ||||
|         $this->entityManager->flush(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * For testing this method, we mock the authorization helper to return different Scope that a user | ||||
|      * can see, or that a user can see confidential periods. | ||||
|      * | ||||
|      * @param array<Scope> $scopeUserCanSee | ||||
|      * @param array<Scope> $scopeUserCanSeeConfidential | ||||
|      * @param array<AccompanyingPeriod> $expectedPeriod | ||||
|      * @dataProvider provideDataForFindByPerson | ||||
|      */ | ||||
|     public function testFindByPersonTestUser(User $user, Person $person, array $scopeUserCanSee, array $scopeUserCanSeeConfidential, array $expectedPeriod, string $message): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->getUser()->willReturn($user); | ||||
|  | ||||
|         $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); | ||||
|         $authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, Argument::any()) | ||||
|             ->willReturn($scopeUserCanSee); | ||||
|         $authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, Argument::any()) | ||||
|             ->willReturn($scopeUserCanSeeConfidential); | ||||
|  | ||||
|         $repository = new AccompanyingPeriodACLAwareRepository( | ||||
|             $this->accompanyingPeriodRepository, | ||||
|             $security->reveal(), | ||||
|             $authorizationHelper->reveal(), | ||||
|             $this->centerResolverManager | ||||
|         ); | ||||
|  | ||||
|         $actuals = $repository->findByPerson($person, AccompanyingPeriodVoter::SEE); | ||||
|         $expectedIds = array_map(fn (AccompanyingPeriod $period) => $period->getId(), $expectedPeriod); | ||||
|  | ||||
|         self::assertCount(count($expectedPeriod), $actuals, $message); | ||||
|         foreach ($actuals as $actual) { | ||||
|             self::assertContains($actual->getId(), $expectedIds); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function provideDataForFindByPerson(): iterable | ||||
|     { | ||||
|         $this->setUp(); | ||||
|  | ||||
|         if (null === $user = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u")->setMaxResults(1)->getSingleResult()) { | ||||
|             throw new \RuntimeException("no user found"); | ||||
|         } | ||||
|  | ||||
|         if (null === $anotherUser = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u WHERE u.id != :uid")->setParameter('uid', $user->getId()) | ||||
|             ->setMaxResults(1)->getSingleResult()) { | ||||
|             throw new \RuntimeException("no user found"); | ||||
|         } | ||||
|  | ||||
|         [$person, $anotherPerson, $person2, $person3] = $this->entityManager | ||||
|             ->createQuery("SELECT p FROM " . Person::class . " p WHERE SIZE(p.accompanyingPeriodParticipations) = 0") | ||||
|             ->setMaxResults(4) | ||||
|             ->getResult(); | ||||
|  | ||||
|         if (null === $person || null === $anotherPerson || null === $person2 || null === $person3) { | ||||
|             throw new \RuntimeException("no person found"); | ||||
|         } | ||||
|  | ||||
|         $scopes = $this->scopeRepository->findAll(); | ||||
|  | ||||
|         if (3 > count($scopes)) { | ||||
|             throw new \RuntimeException("not enough scopes for this test"); | ||||
|         } | ||||
|         $scopesCanSee = [ $scopes[0] ]; | ||||
|         $scopesGroup2 = [ $scopes[1] ]; | ||||
|  | ||||
|         // case: a period is in draft state | ||||
|         $period = $this->buildPeriod($person, $scopesCanSee, $user, false); | ||||
|  | ||||
|         yield [$user, $person, $scopesCanSee, [], [$period], "a user can see his period during draft state"]; | ||||
|  | ||||
|         // another user is not allowed to see this period, because it is in DRAFT state | ||||
|         yield [$anotherUser, $person, $scopesCanSee, [], [], "another user is not allowed to see the period of someone else in draft state"]; | ||||
|  | ||||
|         // the period is confirmed | ||||
|         $period = $this->buildPeriod($anotherPerson, $scopesCanSee, $user, true); | ||||
|  | ||||
|         // the other user can now see it | ||||
|         yield [$user, $anotherPerson, $scopesCanSee, [], [$period], "a user see his period when confirmed"]; | ||||
|         yield [$anotherUser, $anotherPerson, $scopesCanSee, [], [$period], "another user with required scopes is allowed to see the period when not draft"]; | ||||
|         yield [$anotherUser, $anotherPerson, $scopesGroup2, [], [], "another user without the required scopes is not allowed to see the period when not draft"]; | ||||
|  | ||||
|         // this period will be confidential | ||||
|         $period = $this->buildPeriod($person2, $scopesCanSee, $user, true); | ||||
|         $period->setConfidential(true)->setUser($user, true); | ||||
|  | ||||
|         yield [$user, $person2, $scopesCanSee, [], [$period], "a user see his period when confirmed and confidential with required scopes"]; | ||||
|         yield [$user, $person2, $scopesGroup2, [], [$period], "a user see his period when confirmed and confidential without required scopes"]; | ||||
|         yield [$anotherUser, $person2, $scopesCanSee, [], [], "a user don't see a confidential period, even if he has required scopes"]; | ||||
|         yield [$anotherUser, $person2, $scopesCanSee, $scopesCanSee, [$period], "a user see the period when confirmed and confidential if he has required scope to see the period"]; | ||||
|  | ||||
|         // period draft with creator = null | ||||
|         $period = $this->buildPeriod($person3, $scopesCanSee, null, false); | ||||
|         yield [$user, $person3, $scopesCanSee, [], [$period], "a user see a period when draft if no creator on the period"]; | ||||
|         $this->entityManager->flush(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param Person $person | ||||
|      * @param array<Scope> $scopes | ||||
|      * @return AccompanyingPeriod | ||||
|      */ | ||||
|     private function buildPeriod(Person $person, array $scopes, User|null $creator, bool $confirm): AccompanyingPeriod | ||||
|     { | ||||
|         $period = new AccompanyingPeriod(); | ||||
|         $period->addPerson($person); | ||||
|         if (null !== $creator) { | ||||
|             $period->setCreatedBy($creator); | ||||
|         } | ||||
|  | ||||
|         foreach ($scopes as $scope) { | ||||
|             $period->addScope($scope); | ||||
|         } | ||||
|  | ||||
|         $this->entityManager->persist($period); | ||||
|         self::$periodsIdsToDelete[] = $period->getId(); | ||||
|  | ||||
|         if ($confirm) { | ||||
|             $workflow = $this->registry->get($period, 'accompanying_period_lifecycle'); | ||||
|             $workflow->apply($period, 'confirm'); | ||||
|         } | ||||
|  | ||||
|         return $period; | ||||
|     } | ||||
| } | ||||
| @@ -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 } | ||||
|   | ||||
| @@ -177,3 +177,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 } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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é | ||||
|  | ||||
| @@ -1016,6 +1017,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 | ||||
| @@ -1032,6 +1038,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 ? | ||||
| @@ -1081,8 +1096,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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user