Merge branch 'master' into user_filter_tasks

This commit is contained in:
Julie Lenaerts 2023-07-12 07:53:21 +02:00
commit ef1eb2031e
46 changed files with 2108 additions and 414 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -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). 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 ## v2.3.0 - 2023-06-27
### Feature ### 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. * ([#110](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/110)) Edit saved exports options: the saved exports options (forms, filters, aggregators) are now editable.

View File

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

View File

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

View File

@ -18,9 +18,12 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Command; namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\Exception\UserAbsenceSyncException;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\EventsOnUserSubscriptionCreator; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\EventsOnUserSubscriptionCreator;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSGraphUserRepository; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSGraphUserRepository;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceSync;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use DateInterval; use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -30,32 +33,17 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; 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( public function __construct(
EntityManagerInterface $em, private readonly EntityManagerInterface $em,
EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator, private readonly EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator,
LoggerInterface $logger, private readonly LoggerInterface $logger,
MapCalendarToUser $mapCalendarToUser, private readonly MapCalendarToUser $mapCalendarToUser,
MSGraphUserRepository $userRepository private readonly UserRepositoryInterface $userRepository,
private readonly MSUserAbsenceSync $userAbsenceSync,
) { ) {
parent::__construct('chill:calendar:msgraph-user-map-subscribe'); 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 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 */ /** @var DateInterval $interval the interval before the end of the expiration */
$interval = new DateInterval('P1D'); $interval = new DateInterval('P1D');
$expiration = (new DateTimeImmutable('now'))->add(new DateInterval($input->getOption('subscription-duration'))); $expiration = (new DateTimeImmutable('now'))->add(new DateInterval($input->getOption('subscription-duration')));
$total = $this->userRepository->countByMostOldSubscriptionOrWithoutSubscriptionOrData($interval); $users = $this->userRepository->findAllAsArray('fr');
$created = 0; $created = 0;
$renewed = 0; $renewed = 0;
$this->logger->info(self::class . ' the number of user to get - renew', [ $this->logger->info(self::class . ' start user to get - renew', [
'total' => $total,
'expiration' => $expiration->format(DateTimeImmutable::ATOM), 'expiration' => $expiration->format(DateTimeImmutable::ATOM),
]); ]);
while ($offset < $total) { foreach ($users as $u) {
$users = $this->userRepository->findByMostOldSubscriptionOrWithoutSubscriptionOrData( ++$offset;
$interval,
$limit,
$offset
);
foreach ($users as $user) { if (false === $u['enabled']) {
if (!$this->mapCalendarToUser->hasUserId($user)) { continue;
$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;
} }
$this->em->flush(); $user = $this->userRepository->find($u['id']);
$this->em->clear();
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', [ $this->logger->warning(self::class . ' process executed', [
'created' => $created, 'created' => $created,
'renewed' => $renewed, 'renewed' => $renewed,
]); ]);
$output->writeln("users synchronized");
return 0; return 0;
} }
@ -152,7 +166,7 @@ class MapAndSubscribeUserCalendarCommand extends Command
parent::configure(); parent::configure();
$this $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( ->addOption(
'renew-before-end-interval', 'renew-before-end-interval',
'r', 'r',

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Exception;
class UserAbsenceSyncException extends \LogicException
{
public function __construct(string $message = "", int $code = 20_230_706, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@ -1,84 +0,0 @@
<?php
/**
* Chill is a software for social workers.
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use DateInterval;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use function strtr;
/**
* Contains classes and methods for fetching users with some calendar metadatas.
*/
class MSGraphUserRepository
{
private const MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH = <<<'SQL'
select
{select}
from users u
where
NOT attributes ?? 'msgraph'
OR NOT attributes->'msgraph' ?? 'subscription_events_expiration'
OR (attributes->'msgraph' ?? 'subscription_events_expiration' AND (attributes->'msgraph'->>'subscription_events_expiration')::int < EXTRACT(EPOCH FROM (NOW() + :interval::interval)))
LIMIT :limit OFFSET :offset
;
SQL;
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function countByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval): int
{
$rsm = new ResultSetMapping();
$rsm->addScalarResult('c', 'c');
$sql = strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, [
'{select}' => 'COUNT(u) AS c',
'LIMIT :limit OFFSET :offset' => '',
]);
return $this->entityManager->createNativeQuery($sql, $rsm)->setParameters([
'interval' => $interval,
])->getSingleScalarResult();
}
/**
* @return array|User[]
*/
public function findByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval, int $limit = 50, int $offset = 0): array
{
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(User::class, 'u');
return $this->entityManager->createNativeQuery(
strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, ['{select}' => $rsm->generateSelectClause()]),
$rsm
)->setParameters([
'interval' => $interval,
'limit' => $limit,
'offset' => $offset,
])->getResult();
}
}

View File

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

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
interface MSUserAbsenceReaderInterface
{
/**
* @throw UserAbsenceSyncException when the data cannot be reached or is not valid from microsoft
*/
public function isUserAbsent(User $user): bool|null;
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
readonly class MSUserAbsenceSync
{
public function __construct(
private MSUserAbsenceReaderInterface $absenceReader,
private ClockInterface $clock,
private LoggerInterface $logger,
) {
}
public function syncUserAbsence(User $user): void
{
$absence = $this->absenceReader->isUserAbsent($user);
if (null === $absence) {
return;
}
if ($absence === $user->isAbsent()) {
// nothing to do
return;
}
$this->logger->info("will change user absence", ['userId' => $user->getId()]);
if ($absence) {
$this->logger->debug("make user absent", ['userId' => $user->getId()]);
$user->setAbsenceStart($this->clock->now());
} else {
$this->logger->debug("make user present", ['userId' => $user->getId()]);
$user->setAbsenceStart(null);
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Tests\RemoteCalendar\Connector\MSGraph;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReader;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReaderInterface;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceSync;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
/**
* @internal
* @coversNothing
*/
class MSUserAbsenceSyncTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider provideDataTestSyncUserAbsence
*/
public function testSyncUserAbsence(User $user, ?bool $absenceFromMicrosoft, bool $expectedAbsence, ?\DateTimeImmutable $expectedAbsenceStart, string $message): void
{
$userAbsenceReader = $this->prophesize(MSUserAbsenceReaderInterface::class);
$userAbsenceReader->isUserAbsent($user)->willReturn($absenceFromMicrosoft);
$clock = new MockClock(new \DateTimeImmutable('2023-07-01T12:00:00'));
$syncer = new MSUserAbsenceSync($userAbsenceReader->reveal(), $clock, new NullLogger());
$syncer->syncUserAbsence($user);
self::assertEquals($expectedAbsence, $user->isAbsent(), $message);
self::assertEquals($expectedAbsenceStart, $user->getAbsenceStart(), $message);
}
public function provideDataTestSyncUserAbsence(): iterable
{
yield [new User(), false, false, null, "user present remains present"];
yield [new User(), true, true, new \DateTimeImmutable('2023-07-01T12:00:00'), "user present becomes absent"];
$user = new User();
$user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00"));
yield [$user, true, true, $abs, "user absent remains absent"];
$user = new User();
$user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00"));
yield [$user, false, false, null, "user absent becomes present"];
yield [new User(), null, false, null, "user not syncable: presence do not change"];
$user = new User();
$user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00"));
yield [$user, null, true, $abs, "user not syncable: absence do not change"];
}
}

View File

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

View File

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

View File

@ -195,10 +195,6 @@ class AuthorizationHelper implements AuthorizationHelperInterface
/** /**
* Return all reachable scope for a given user, center and role. * 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 public function getReachableScopes(UserInterface $user, string $role, Center|array $center): array
{ {

View File

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

View File

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

View File

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

View File

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

View File

@ -219,13 +219,13 @@ class AccompanyingPeriodController extends AbstractController
]); ]);
$this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event); $this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event);
$accompanyingPeriodsRaw = $this->accompanyingPeriodACLAwareRepository $accompanyingPeriods = $this->accompanyingPeriodACLAwareRepository
->findByPerson($person, AccompanyingPeriodVoter::SEE); ->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 // 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', [ return $this->render('@ChillPerson/AccompanyingPeriod/list.html.twig', [
'accompanying_periods' => $accompanyingPeriods, 'accompanying_periods' => $accompanyingPeriods,

View File

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

View File

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

View File

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

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class JobWorkingOnCourseAggregator implements AggregatorInterface
{
private const COLUMN_NAME = 'user_working_on_course_job_id';
public function __construct(
private UserJobRepositoryInterface $userJobRepository,
private TranslatableStringHelperInterface $translatableStringHelper,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): \Closure
{
return function (int|string|null $jobId) {
if (null === $jobId || '' === $jobId) {
return '';
}
if ('_header' === $jobId) {
return 'export.aggregator.course.by_job_working.job';
}
if (null === $job = $this->userJobRepository->find((int) $jobId)) {
return '';
}
return $this->translatableStringHelper->localize($job->getLabel());
};
}
public function getQueryKeys($data)
{
return [self::COLUMN_NAME];
}
public function getTitle()
{
return 'export.aggregator.course.by_job_working.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('acpinfo', $qb->getAllAliases(), true)) {
$qb->leftJoin(
AccompanyingPeriodInfo::class,
'acpinfo',
Join::WITH,
'acp.id = IDENTITY(acpinfo.accompanyingPeriod)'
);
}
if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) {
$qb->leftJoin('acpinfo.user', 'acpinfo_user');
}
$qb->addSelect('IDENTITY(acpinfo_user.userJob) AS ' . self::COLUMN_NAME);
$qb->addGroupBy(self::COLUMN_NAME);
}
public function applyOn()
{
return Declarations::ACP_TYPE;
}
}

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class ScopeWorkingOnCourseAggregator implements AggregatorInterface
{
private const COLUMN_NAME = 'user_working_on_course_scope_id';
public function __construct(
private ScopeRepositoryInterface $scopeRepository,
private TranslatableStringHelperInterface $translatableStringHelper,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): \Closure
{
return function (int|string|null $scopeId) {
if (null === $scopeId || '' === $scopeId) {
return '';
}
if ('_header' === $scopeId) {
return 'export.aggregator.course.by_scope_working.scope';
}
if (null === $scope = $this->scopeRepository->find((int) $scopeId)) {
return '';
}
return $this->translatableStringHelper->localize($scope->getName());
};
}
public function getQueryKeys($data)
{
return [self::COLUMN_NAME];
}
public function getTitle()
{
return 'export.aggregator.course.by_scope_working.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('acpinfo', $qb->getAllAliases(), true)) {
$qb->leftJoin(
AccompanyingPeriodInfo::class,
'acpinfo',
Join::WITH,
'acp.id = IDENTITY(acpinfo.accompanyingPeriod)'
);
}
if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) {
$qb->leftJoin('acpinfo.user', 'acpinfo_user');
}
$qb->addSelect('IDENTITY(acpinfo_user.mainScope) AS ' . self::COLUMN_NAME);
$qb->addGroupBy(self::COLUMN_NAME);
}
public function applyOn()
{
return Declarations::ACP_TYPE;
}
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodInfo;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class UserWorkingOnCourseAggregator implements AggregatorInterface
{
private const COLUMN_NAME = 'user_working_on_course_user_id';
public function __construct(
private UserRender $userRender,
private UserRepositoryInterface $userRepository,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): \Closure
{
return function (int|string|null $userId) {
if (null === $userId || '' === $userId) {
return '';
}
if ('_header' === $userId) {
return 'export.aggregator.course.by_user_working.user';
}
if (null === $user = $this->userRepository->find((int) $userId)) {
return '';
}
return $this->userRender->renderString($user, []);
};
}
public function getQueryKeys($data)
{
return [self::COLUMN_NAME];
}
public function getTitle()
{
return 'export.aggregator.course.by_user_working.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
if (!in_array('acpinfo', $qb->getAllAliases(), true)) {
$qb->leftJoin(
AccompanyingPeriodInfo::class,
'acpinfo',
Join::WITH,
'acp.id = IDENTITY(acpinfo.accompanyingPeriod)'
);
}
if (!in_array('acpinfo_user', $qb->getAllAliases(), true)) {
$qb->leftJoin('acpinfo.user', 'acpinfo_user');
}
$qb->addSelect('acpinfo_user.id AS ' . self::COLUMN_NAME);
$qb->addGroupBy('acpinfo_user.id');
}
public function applyOn()
{
return Declarations::ACP_TYPE;
}
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Export\Aggregator\PersonAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Export\Declarations;
use Closure;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class CenterAggregator implements AggregatorInterface
{
private const COLUMN_NAME = 'person_center_aggregator';
public function __construct(
private CenterRepositoryInterface $centerRepository,
private RollingDateConverterInterface $rollingDateConverter,
) {
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('at_date', PickRollingDateType::class, [
'label' => 'export.aggregator.person.by_center.at_date',
]);
}
public function getFormDefaultData(): array
{
return [
'at_date' => new RollingDate(RollingDate::T_TODAY)
];
}
public function getLabels($key, array $values, $data): Closure
{
return function (int|string|null $value) {
if (null === $value || '' === $value) {
return '';
}
if ('_header' === $value) {
return 'export.aggregator.person.by_center.center';
}
return (string) $this->centerRepository->find((int) $value)?->getName();
};
}
public function getQueryKeys($data)
{
return [self::COLUMN_NAME];
}
public function getTitle()
{
return 'export.aggregator.person.by_center.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$alias = 'pers_center_agg';
$atDate = 'pers_center_agg_at_date';
$qb->leftJoin('person.centerHistory', $alias);
$qb
->andWhere(
$qb->expr()->lte($alias.'.startDate', ':'.$atDate),
)->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.endDate'),
$qb->expr()->gt($alias.'.endDate', ':'.$atDate)
)
);
$qb->setParameter($atDate, $this->rollingDateConverter->convert($data['at_date']));
$qb->addSelect("IDENTITY({$alias}.center) AS " . self::COLUMN_NAME);
$qb->addGroupBy(self::COLUMN_NAME);
}
public function applyOn()
{
return Declarations::PERSON_TYPE;
}
}

View File

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

View File

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

View File

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

View File

@ -13,7 +13,10 @@ namespace Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\FilterInterface; use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Form\Type\PickUserDynamicType; 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\MainBundle\Templating\Entity\UserRender;
use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Export\Declarations; use Chill\PersonBundle\Export\Declarations;
@ -27,11 +30,9 @@ use Symfony\Component\Form\FormBuilderInterface;
*/ */
readonly class UserWorkingOnCourseFilter implements FilterInterface 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( public function __construct(
private UserRender $userRender, private UserRender $userRender,
private RollingDateConverterInterface $rollingDateConverter,
) { ) {
} }
@ -40,11 +41,23 @@ readonly class UserWorkingOnCourseFilter implements FilterInterface
$builder $builder
->add('users', PickUserDynamicType::class, [ ->add('users', PickUserDynamicType::class, [
'multiple' => true, '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 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 public function getTitle(): string
@ -55,7 +68,7 @@ readonly class UserWorkingOnCourseFilter implements FilterInterface
public function describeAction($data, $format = 'string'): array public function describeAction($data, $format = 'string'): array
{ {
return [ 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( '%users%' => implode(
', ', ', ',
array_map( array_map(
@ -63,6 +76,8 @@ readonly class UserWorkingOnCourseFilter implements FilterInterface
$data['users'] $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 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 $qb
->andWhere( ->andWhere(
$qb->expr()->exists( $qb->expr()->exists(
"SELECT 1 FROM " . AccompanyingPeriod\AccompanyingPeriodInfo::class . " " . self::AI_ALIAS . " " . "SELECT 1 FROM " . AccompanyingPeriod\AccompanyingPeriodInfo::class . " {$ai_alias} " .
"WHERE " . self::AI_ALIAS . ".user IN (:" . self::AI_USERS .") AND IDENTITY(" . self::AI_ALIAS . ".accompanyingPeriod) = acp.id" "WHERE {$ai_alias}.user IN (:{$ai_users}) AND IDENTITY({$ai_alias}.accompanyingPeriod) = acp.id AND {$ai_alias}.infoDate >= :{$start} and {$ai_alias}.infoDate < :{$end}"
) )
) )
->setParameter(self::AI_USERS, $data['users']) ->setParameter($ai_users, $data['users'])
->setParameter($start, $this->rollingDateConverter->convert($data['start_date']))
->setParameter($end, $this->rollingDateConverter->convert($data['end_date']))
; ;
} }

View File

@ -12,107 +12,93 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository; namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\PostalCode; use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use DateTime; use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Repository\AccompanyingPeriodACLAwareRepositoryTest;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use function count; use function count;
final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodACLAwareRepositoryInterface /**
* @see AccompanyingPeriodACLAwareRepositoryTest
*/
final readonly class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodACLAwareRepositoryInterface
{ {
private AccompanyingPeriodRepository $accompanyingPeriodRepository; private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private AuthorizationHelper $authorizationHelper; private AuthorizationHelperForCurrentUserInterface $authorizationHelper;
private CenterResolverDispatcherInterface $centerResolverDispatcher; private CenterResolverManagerInterface $centerResolver;
private Security $security; private Security $security;
public function __construct( public function __construct(
AccompanyingPeriodRepository $accompanyingPeriodRepository, AccompanyingPeriodRepository $accompanyingPeriodRepository,
Security $security, Security $security,
AuthorizationHelper $authorizationHelper, AuthorizationHelperForCurrentUserInterface $authorizationHelper,
CenterResolverDispatcherInterface $centerResolverDispatcher CenterResolverManagerInterface $centerResolverDispatcher
) { ) {
$this->accompanyingPeriodRepository = $accompanyingPeriodRepository; $this->accompanyingPeriodRepository = $accompanyingPeriodRepository;
$this->security = $security; $this->security = $security;
$this->authorizationHelper = $authorizationHelper; $this->authorizationHelper = $authorizationHelper;
$this->centerResolverDispatcher = $centerResolverDispatcher; $this->centerResolver = $centerResolverDispatcher;
} }
/** public function buildQueryOpenedAccompanyingCourseByUserAndPostalCodes(?User $user, array $postalCodes = []): QueryBuilder
* @param array|PostalCode[]
*
* @return QueryBuilder
*/
public function buildQueryOpenedAccompanyingCourseByUser(?User $user, array $postalCodes = [])
{ {
$qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap'); $qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap');
$qb->where($qb->expr()->eq('ap.user', ':user')) $qb->where($qb->expr()->eq('ap.user', ':user'))
->andWhere( ->andWhere(
$qb->expr()->neq('ap.step', ':draft'), $qb->expr()->neq('ap.step', ':draft'),
$qb->expr()->orX( $qb->expr()->neq('ap.step', ':closed'),
$qb->expr()->isNull('ap.closingDate'),
$qb->expr()->gt('ap.closingDate', ':now')
)
) )
->setParameter('user', $user) ->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) { if ([] !== $postalCodes) {
$qb->join('ap.locationHistories', 'location_history') $qb->join('ap.locationHistories', 'location_history', Join::WITH, 'location_history.endDate IS NULL')
->leftJoin(PersonHouseholdAddress::class, 'person_address', Join::WITH, 'IDENTITY(location_history.personLocation) = IDENTITY(person_address.person)') ->leftJoin(Person\PersonCurrentAddress::class, 'person_address', Join::WITH, 'IDENTITY(location_history.personLocation) = IDENTITY(person_address.person)')
->join( ->join(
Address::class, Address::class,
'address', 'address',
Join::WITH, 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( ->andWhere(
$qb->expr()->orX( $qb->expr()->in('postcode.code', ':postal_codes')
$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')
)
)
)
) )
->andWhere( ->setParameter('postal_codes', array_map(fn (PostalCode $postalCode) => $postalCode->getCode(), $postalCodes));
$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);
} }
return $qb; return $qb;
} }
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function countByUnDispatched(array $jobs, array $services, array $administrativeLocations): int 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)'); $qb->select('COUNT(ap)');
@ -125,22 +111,12 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
return 0; return 0;
} }
return $this->buildQueryOpenedAccompanyingCourseByUser($user, $postalCodes) $qb = $this->buildQueryOpenedAccompanyingCourseByUserAndPostalCodes($user, $postalCodes);
->select('COUNT(ap)') $qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false);
->getQuery()
->getSingleScalarResult();
}
public function countByUserOpenedAccompanyingPeriod(?User $user): int $qb->select('COUNT(DISTINCT ap)');
{
if (null === $user) {
return 0;
}
return $this->buildQueryOpenedAccompanyingCourseByUser($user) return $qb->getQuery()->getSingleScalarResult();
->select('COUNT(ap)')
->getQuery()
->getSingleScalarResult();
} }
public function findByPerson( public function findByPerson(
@ -152,10 +128,14 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
): array { ): array {
$qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap'); $qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap');
$scopes = $this->authorizationHelper $scopes = $this->authorizationHelper
->getReachableCircles( ->getReachableScopes(
$this->security->getUser(),
$role, $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)) { if (0 === count($scopes)) {
@ -165,12 +145,44 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
$qb $qb
->join('ap.participations', 'participation') ->join('ap.participations', 'participation')
->where($qb->expr()->eq('participation.person', ':person')) ->where($qb->expr()->eq('participation.person', ':person'))
->andWhere( ->setParameter('person', $person);
$qb->expr()->orX(
'ap.confidential = FALSE', $qb = $this->addACLClauses($qb, $scopes, $scopesCanSeeConfidential);
$qb->expr()->eq('ap.user', ':user') $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( ->andWhere(
$qb->expr()->orX( $qb->expr()->orX(
$qb->expr()->neq('ap.step', ':draft'), $qb->expr()->neq('ap.step', ':draft'),
@ -181,40 +193,67 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
) )
) )
->setParameter('draft', AccompanyingPeriod::STEP_DRAFT) ->setParameter('draft', AccompanyingPeriod::STEP_DRAFT)
->setParameter('person', $person)
->setParameter('user', $this->security->getUser()) ->setParameter('user', $this->security->getUser())
->setParameter('creator', $this->security->getUser()); ->setParameter('creator', $this->security->getUser());
// add join condition for scopes // add join condition for scopes
$orx = $qb->expr()->orX( $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') $qb->expr()->eq('ap.step', ':draft')
); );
foreach ($scopes as $key => $scope) { foreach ($scopesCanSee as $key => $scope) {
$orx->add($qb->expr()->orX( // 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()->isMemberOf(':scope_' . $key, 'ap.scopes'),
$qb->expr()->eq('ap.user', ':user') $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('scope_' . $key, $scope);
$qb->setParameter('user', $this->security->getUser());
} }
$qb->andWhere($orx); $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'); $qb->select('ap');
if (null !== $limit) { $qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false);
$qb->setMaxResults($limit); $qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
@ -225,76 +264,80 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
return []; return [];
} }
$qb = $this->buildQueryOpenedAccompanyingCourseByUser($user); $qb = $this->buildQueryOpenedAccompanyingCourseByUserAndPostalCodes($user, $postalCodes);
$qb = $this->addACLMultiCenterOnQuery($qb, $this->buildCenterOnScope(), false);
$qb->setFirstResult($offset) $qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset);
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('ap.' . $field, $direction);
}
return $qb->getQuery()->getResult(); 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) { $user = $this->security->getUser();
return [];
}
$qb = $this->buildQueryOpenedAccompanyingCourseByUser($user); if (0 === count($centerScopes) || !$user instanceof 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)) {
return $qb->andWhere("'FALSE' = 'TRUE'"); return $qb->andWhere("'FALSE' = 'TRUE'");
} }
foreach ($centers as $key => $center) { $orX = $qb->expr()->orX();
$scopes = $this->authorizationHelper
->getReachableCircles(
$this->security->getUser(),
AccompanyingPeriodVoter::SEE,
$center
);
$idx = 0;
foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) {
$and = $qb->expr()->andX( $and = $qb->expr()->andX(
$qb->expr()->exists('SELECT part FROM ' . AccompanyingPeriodParticipation::class . ' part ' . $qb->expr()->exists(
"JOIN part.person p WHERE part.accompanyingPeriod = ap.id AND p.center = :center_{$key}") '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); $qb->setParameter('center_' . $idx, $center);
$orScope = $qb->expr()->orX();
foreach ($scopes as $skey => $scope) { $orScopeInsideCenter = $qb->expr()->orX(
$orScope->add( // even if the scope is not in one authorized, the user can see the course if it is in DRAFT state
$qb->expr()->isMemberOf(':scope_' . $key . '_' . $skey, 'ap.scopes') $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); $orX->add($and);
$idx++;
} }
return $qb->andWhere($orX); return $qb->andWhere($orX);
@ -305,7 +348,7 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
* @param array|Scope[] $services * @param array|Scope[] $services
* @param array|Location[] $locations * @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'); $qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap');
@ -333,8 +376,8 @@ final class AccompanyingPeriodACLAwareRepository implements AccompanyingPeriodAC
$or = $qb->expr()->orX(); $or = $qb->expr()->orX();
foreach ($services as $key => $service) { foreach ($services as $key => $service) {
$or->add($qb->expr()->isMemberOf(':scope_' . $key, 'ap.scopes')); $or->add($qb->expr()->isMemberOf(':scopef_' . $key, 'ap.scopes'));
$qb->setParameter('scope_' . $key, $service); $qb->setParameter('scopef_' . $key, $service);
} }
$qb->andWhere($or); $qb->andWhere($or);
} }

View File

@ -31,28 +31,28 @@ interface AccompanyingPeriodACLAwareRepositoryInterface
*/ */
public function countByUserAndPostalCodesOpenedAccompanyingPeriod(?User $user, array $postalCodes): int; public function countByUserAndPostalCodesOpenedAccompanyingPeriod(?User $user, array $postalCodes): int;
public function countByUserOpenedAccompanyingPeriod(?User $user): int; /**
* @return array<AccompanyingPeriod>
*/
public function findByPerson( public function findByPerson(
Person $person, Person $person,
string $role, string $role,
?array $orderBy = [], ?array $orderBy = [],
?int $limit = null, ?int $limit = null,
?int $offset = null ?int $offset = null
): array; ): array;
/** /**
* @param array|UserJob[] $jobs if empty, does not take this argument into account * @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 * @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 * @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 findByUserAndPostalCodesOpenedAccompanyingPeriod(?User $user, array $postalCodes, array $orderBy = [], int $limit = 0, int $offset = 50): array;
public function findByUserOpenedAccompanyingPeriod(?User $user, array $orderBy = [], int $limit = 0, int $offset = 50): array;
} }

View File

@ -1,9 +1,11 @@
<div class="accompanying-course-work"> <div class="accompanying-course-work">
{% for w in works | slice(0,5) %} {% for w in works | slice(0,5) %}
<a href="{{ chill_path_add_return_path('chill_person_accompanying_period_work_edit', { 'id': w.id }) }}" <a href="{%- if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', w) -%}
class="dashboard-link" title="{{ 'crud.social_action.title_link'|trans }}"> {{- 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"> <div class="dashboard">
<span class="title_label"></span> <span class="title_label"></span>
<span class="title_action"><span class="like-h3">{{ w.socialAction|chill_entity_render_string }}</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> <b>{{ w.startDate|format_date('short') }}</b>
</li> </li>
{% if w.endDate %} {% if w.endDate %}
<li> <li>
<span class="item-key">{{ 'accompanying_course_work.end_date'|trans ~ ' : ' }}</span> <span class="item-key">{{ 'accompanying_course_work.end_date'|trans ~ ' : ' }}</span>
<b>{{ w.endDate|format_date('short') }}</b> <b>{{ w.endDate|format_date('short') }}</b>
</li> </li>
@ -23,20 +25,20 @@
<ul class="small_in_title"> <ul class="small_in_title">
{% if w.handlingThierParty %} {% if w.handlingThierParty %}
<li> <li>
<span class="item-key">{{ 'Thirdparty handling'|trans ~ ' : ' }}</span> <span class="item-key">{{ 'Thirdparty handling'|trans ~ ' : ' }}</span>
<span class="badge-thirdparty">{{ w.handlingThierParty|chill_entity_render_box }}</span> <span class="badge-thirdparty">{{ w.handlingThierParty|chill_entity_render_box }}</span>
</li> </li>
{% endif %} {% endif %}
{% if w.referrers %} {% if w.referrers %}
<li> <li>
<span class="item-key">{{ 'Referrers'|trans ~ ' : ' }}</span> <span class="item-key">{{ 'Referrers'|trans ~ ' : ' }}</span>
{% for u in w.referrers %} {% for u in w.referrers %}
<span class="badge-user">{{ u|chill_entity_render_box }}</span> <span class="badge-user">{{ u|chill_entity_render_box }}</span>
{% endfor %} {% endfor %}
{% if w.referrers|length == 0 %} {% if w.referrers|length == 0 %}
<span class="chill-no-data-statement">{{ 'Not given'|trans }}</span> <span class="chill-no-data-statement">{{ 'Not given'|trans }}</span>
{% endif %} {% endif %}
</li> </li>
{% endif %} {% endif %}
<li class="associated-persons"> <li class="associated-persons">
@ -65,8 +67,8 @@
</span> </span>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

View File

@ -42,11 +42,6 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
self::RE_OPEN_COURSE, 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'; 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'; 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 Security $security;
private VoterHelperInterface $voterHelper; private VoterHelperInterface $voterHelper;
@ -131,7 +131,6 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
return [ return [
self::SEE, self::SEE,
self::SEE_DETAILS, self::SEE_DETAILS,
self::CONFIDENTIAL_CRUD,
self::CREATE, self::CREATE,
self::EDIT, self::EDIT,
self::DELETE, self::DELETE,
@ -139,6 +138,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
self::TOGGLE_CONFIDENTIAL_ALL, self::TOGGLE_CONFIDENTIAL_ALL,
self::REASSIGN_BULK, self::REASSIGN_BULK,
self::STATS, self::STATS,
self::SEE_CONFIDENTIAL_ALL,
]; ];
} }
@ -149,7 +149,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
public function getRolesWithoutScope(): array public function getRolesWithoutScope(): array
{ {
return [self::REASSIGN_BULK]; return [];
} }
protected function supports($attribute, $subject) protected function supports($attribute, $subject)
@ -216,7 +216,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH
// if confidential, only the referent can see it // if confidential, only the referent can see it
if ($subject->isConfidential()) { if ($subject->isConfidential()) {
if ($this->voterHelper->voteOnAttribute(self::CONFIDENTIAL_CRUD, $subject, $token)) { if ($this->voterHelper->voteOnAttribute(self::SEE_CONFIDENTIAL_ALL, $subject, $token)) {
return true; return true;
} }

View File

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

View File

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

View File

@ -135,6 +135,14 @@ services:
tags: tags:
- { name: chill.export_filter, alias: accompanyingcourse_user_working_on_filter } - { 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: Chill\PersonBundle\Export\Filter\AccompanyingCourseFilters\HavingAnAccompanyingPeriodInfoWithinDatesFilter:
tags: tags:
- { name: chill.export_filter, alias: accompanyingcourse_info_within_filter } - { name: chill.export_filter, alias: accompanyingcourse_info_within_filter }
@ -231,3 +239,15 @@ services:
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\CreatorJobAggregator: Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\CreatorJobAggregator:
tags: tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_creator_job_aggregator } - { name: chill.export_aggregator, alias: accompanyingcourse_creator_job_aggregator }
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\UserWorkingOnCourseAggregator:
tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_user_working_on_course_aggregator }
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\JobWorkingOnCourseAggregator:
tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_job_working_on_course_aggregator }
Chill\PersonBundle\Export\Aggregator\AccompanyingCourseAggregators\ScopeWorkingOnCourseAggregator:
tags:
- { name: chill.export_aggregator, alias: accompanyingcourse_scope_working_on_course_aggregator }

View File

@ -177,3 +177,8 @@ services:
tags: tags:
- { name: chill.export_aggregator, alias: person_household_compo_aggregator } - { name: chill.export_aggregator, alias: person_household_compo_aggregator }
Chill\PersonBundle\Export\Aggregator\PersonAggregators\CenterAggregator:
tags:
- { name: chill.export_aggregator, alias: person_center_aggregator }

View File

@ -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_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_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_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_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_CREATE: Créer une action d'accompagnement
CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_DELETE: Supprimer 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 Exports of accompanying courses: Exports des parcours d'accompagnement
Count accompanying courses: Nombre de parcours Count accompanying courses: Nombre de parcours
Count accompanying courses by various parameters: Compte le nombre de parcours en fonction de différents filtres. 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. 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é 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 Household composition: Composition du ménage
Group course by household composition: Grouper les usagers par composition familiale Group course by household composition: Grouper les usagers par composition familiale
Calc date: Date de calcul de la composition du ménage 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: course:
by_referrer: by_referrer:
Computation date for referrer: Date à laquelle le référent était actif Computation date for referrer: Date à laquelle le référent était actif
@ -1032,6 +1038,15 @@ export:
Number of actions: Nombre d'actions Number of actions: Nombre d'actions
by_creator_job: by_creator_job:
Creator's job: Métier du créateur 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: course_work:
by_current_action: by_current_action:
Current action ?: Action en cours ? Current action ?: Action en cours ?
@ -1081,8 +1096,20 @@ export:
end_date: Fin de la période 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% 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: by_user_working:
title: Filter les parcours par intervenant title: Filter les parcours par intervenant, entre deux dates
'Filtered by user working on course: only %users%': 'Filtré par intervenants sur le parcours: seulement %users%' '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: by_step:
Filter by step: Filtrer les parcours par statut du parcours 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 Filter by step between dates: Filtrer les parcours par statut du parcours entre deux dates