Compare commits

...

52 Commits

Author SHA1 Message Date
2721872da8 fix bug of user filter not being combined with other filters 2023-09-12 15:40:17 +02:00
ef1eb2031e Merge branch 'master' into user_filter_tasks 2023-07-12 07:53:21 +02:00
197d69ef4a release v2.4.0 2023-07-07 13:21:22 +02:00
9423f4d055 Merge branch '124-sync-user-absence-ms-graph' into 'master'
Feature: sync user absence with microsoft graph api

Closes #124

See merge request Chill-Projet/chill-bundles!571
2023-07-07 11:07:08 +00:00
99d6e9e6b8 Merge branch 'accompanying_period_voter' into 'master'
rights for reassigning and accessing confidential parcours

Closes #99 and #121

See merge request Chill-Projet/chill-bundles!483
2023-07-07 11:06:37 +00:00
8929f4b8a3 Merge branch '116-course-index-link-action-to-view-if-closed' into 'master'
Fixed: rights on the action list in accompanying period main's page

Closes #116

See merge request Chill-Projet/chill-bundles!566
2023-07-06 20:05:25 +00:00
43b7139488 One more changie [ci-skip] 2023-07-06 22:01:45 +02:00
d3251075e9 fix loading of kernel if ms calendar is not created 2023-07-06 21:55:29 +02:00
93a598b549 improve php applying rector rules 2023-07-06 21:45:29 +02:00
9b6e6ec20f add a changie 2023-07-06 21:34:43 +02:00
77d4b13c1b Sync user absence / presence within MapAndSubscribeUserCalendarCommand 2023-07-06 21:33:01 +02:00
2861945a52 Syncer for user absence, from the msgraph reader 2023-07-06 17:29:10 +02:00
5b42b85b50 Read absence from MS graph api 2023-07-06 16:58:24 +02:00
e40b1b9853 Merge branch 'master' into accompanying_period_voter 2023-07-05 22:39:22 +02:00
c19232de35 DX: fix phpstan issues 2023-07-05 22:37:51 +02:00
c95dc23c51 Merge remote-tracking branch 'origin/master' into accompanying_period_voter 2023-07-05 22:27:47 +02:00
0361743ae0 Merge branch '113-add-missing-filters' into 'master'
Add missing aggregators and filters

Closes #113

See merge request Chill-Projet/chill-bundles!567
2023-07-05 20:08:20 +00:00
af4e7f1226 Add changie entry 2023-07-05 22:06:36 +02:00
ff1629cbb7 Separate role "see confidential course" from "reassign bulk" 2023-07-05 22:06:21 +02:00
779eb812b0 Add new role to see confidential right on method AccompanyingPeriodACLAwareRepositoryInterface::findByUserAndPostalCodeOpenedAccompanyingPeriod 2023-07-05 21:56:50 +02:00
a990591e0c handle right to see confidential course on regulation list 2023-07-05 16:23:14 +02:00
52d51264ba FIX [query][user filter] avoid replacement of user parameter in query 2023-07-05 14:57:28 +02:00
4e934653be DX changie added 2023-07-05 14:12:43 +02:00
1ee0e8e350 DX phpstan and csfixer 2023-07-05 13:35:06 +02:00
4da7040a49 FEATURE [user filter] implement query. Selecting multiple users doesn't work 2023-07-05 12:38:42 +02:00
a34b5f8588 Merge remote-tracking branch 'origin/118-design-filterOrder' into user_filter_tasks 2023-07-05 10:56:12 +02:00
0d626fb345 [FEATURE] implement user filter in orderFilterHelper for tasks 2023-07-05 10:55:30 +02:00
25d4b6acbb [FEATURE] allow adding of user filters in filter order - template still to be done 2023-07-05 10:54:37 +02:00
7f9738975c UX: improve FilterOrder box design 2023-07-04 17:53:08 +02:00
a56370d851 DX: fix phpstan issues with more strict type hinting in AuthorizationHelperInterface 2023-07-04 17:02:36 +02:00
3e63b4abf3 UX: improve FilterOrder box design 2023-07-04 16:42:56 +02:00
dd344aed52 Implements right "see confidential course" on method findByPerson
Add unit tests for that
2023-07-04 15:59:39 +02:00
1485d1ce7a Merge branch 'master' into 118-design-filterOrder 2023-07-04 14:56:00 +02:00
a7dbdc2b9d FEATURE [voter][confidential] voter adapted. repository changes left to do 2023-07-03 13:35:34 +02:00
b3d993165d FEATURE: [confidential][voter] bulk assign right should also give right to access confidential parcours 2023-07-03 13:35:34 +02:00
9ccc57bbcb FEATURE [config][voter] config set for relation between bulk_assign and see_confidential 2023-07-03 13:35:34 +02:00
cc0e832cc9 FEATURE [voter][confidential] added right to see confidential periods 2023-07-03 13:35:33 +02:00
c8b62d990a fixes for exports and list 2023-06-30 17:12:09 +02:00
b7df62d4f5 [export] use a rolling date on age aggregator (Person)
This query allow to detects the saved export which won't work any more:

```sql
select
s.id, user_id, description, title, u.label
from chill_main_saved_export s join users u on u.id = s.user_id
WHERE
options->'export'->'export'->'aggregators'->'person_age_aggregator'->'enabled' is not null;
```
2023-06-29 23:15:15 +02:00
5a395b160f [export] add aggregator and filter related to AccompanyingPeriodInfo +
Center aggregator for Person

see #113
2023-06-29 17:53:47 +02:00
393e59e22b DX: Rolling date: allow to receive a null parameter (RollingDate)
When receiving a null parameter (a null rolling date), it will return
null
2023-06-29 17:53:47 +02:00
4a5ac170ba [export] add dates for filter "user working on course" 2023-06-29 17:53:47 +02:00
c019fffbe7 fix cs 2023-06-29 17:53:15 +02:00
31745bc252 [export] order center alphabetically when generating an export 2023-06-29 17:53:15 +02:00
56940d830c fix typos 2023-06-29 17:45:50 +02:00
347eda05df Fix: force the consistency of location in accompanying period
- internally, the entity remove the addressLocation when the
  personLocation is set, and vice-versa;
- this commit add a migration which may solve the case when both case
  happens (priority to personLocation + keep the history)
- add a constraint on the database to avoid such situation
2023-06-29 12:44:28 +02:00
90e8687799 Fixed: [export] rename label on CurrentActionFilter 2023-06-28 17:01:42 +02:00
f19b939bd4 Fixed: rights on the action list in accompanying period main's page
Add is_granted check on the action:

- if update action is allowed, open in update mode;
- if see action is allowed, open in see mode;
- fallback to an inactive link (should not happens)
2023-06-27 11:04:22 +02:00
0e5f1b4ab9 Feature: [activity list] add pagination 2023-06-23 12:44:54 +02:00
f7c11d3567 Feature: Add filters on activity list 2023-06-23 12:27:18 +02:00
51544cfc48 DX: improve typing of a property in UserJob 2023-06-23 12:24:40 +02:00
659dff3d2c DX: Add features to filterOrder
Allow to add single checkboxes and entitychoices to filter order
2023-06-23 12:24:32 +02:00
77 changed files with 3311 additions and 620 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,11 +18,17 @@ use Chill\ActivityBundle\Repository\ActivityACLAwareRepositoryInterface;
use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\ActivityBundle\Repository\ActivityTypeCategoryRepository;
use Chill\ActivityBundle\Repository\ActivityTypeRepositoryInterface;
use Chill\ActivityBundle\Repository\ActivityUserJobRepository;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\LocationRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Privacy\PrivacyEvent;
@@ -47,68 +53,26 @@ use function array_key_exists;
final class ActivityController extends AbstractController
{
private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private ActivityACLAwareRepositoryInterface $activityACLAwareRepository;
private ActivityRepository $activityRepository;
private ActivityTypeCategoryRepository $activityTypeCategoryRepository;
private ActivityTypeRepositoryInterface $activityTypeRepository;
private CenterResolverManagerInterface $centerResolver;
private EntityManagerInterface $entityManager;
private EventDispatcherInterface $eventDispatcher;
private LocationRepository $locationRepository;
private LoggerInterface $logger;
private PersonRepository $personRepository;
private SerializerInterface $serializer;
private ThirdPartyRepository $thirdPartyRepository;
private TranslatorInterface $translator;
private UserRepositoryInterface $userRepository;
public function __construct(
ActivityACLAwareRepositoryInterface $activityACLAwareRepository,
ActivityTypeRepositoryInterface $activityTypeRepository,
ActivityTypeCategoryRepository $activityTypeCategoryRepository,
PersonRepository $personRepository,
ThirdPartyRepository $thirdPartyRepository,
LocationRepository $locationRepository,
ActivityRepository $activityRepository,
AccompanyingPeriodRepository $accompanyingPeriodRepository,
EntityManagerInterface $entityManager,
EventDispatcherInterface $eventDispatcher,
LoggerInterface $logger,
SerializerInterface $serializer,
UserRepositoryInterface $userRepository,
CenterResolverManagerInterface $centerResolver,
TranslatorInterface $translator
private readonly ActivityACLAwareRepositoryInterface $activityACLAwareRepository,
private readonly ActivityTypeRepositoryInterface $activityTypeRepository,
private readonly ActivityTypeCategoryRepository $activityTypeCategoryRepository,
private readonly PersonRepository $personRepository,
private readonly ThirdPartyRepository $thirdPartyRepository,
private readonly LocationRepository $locationRepository,
private readonly ActivityRepository $activityRepository,
private readonly AccompanyingPeriodRepository $accompanyingPeriodRepository,
private readonly EntityManagerInterface $entityManager,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly LoggerInterface $logger,
private readonly SerializerInterface $serializer,
private readonly UserRepositoryInterface $userRepository,
private readonly CenterResolverManagerInterface $centerResolver,
private readonly TranslatorInterface $translator,
private readonly FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly PaginatorFactory $paginatorFactory,
) {
$this->activityACLAwareRepository = $activityACLAwareRepository;
$this->activityTypeRepository = $activityTypeRepository;
$this->activityTypeCategoryRepository = $activityTypeCategoryRepository;
$this->personRepository = $personRepository;
$this->thirdPartyRepository = $thirdPartyRepository;
$this->locationRepository = $locationRepository;
$this->activityRepository = $activityRepository;
$this->accompanyingPeriodRepository = $accompanyingPeriodRepository;
$this->entityManager = $entityManager;
$this->eventDispatcher = $eventDispatcher;
$this->logger = $logger;
$this->serializer = $serializer;
$this->userRepository = $userRepository;
$this->centerResolver = $centerResolver;
$this->translator = $translator;
}
/**
@@ -289,14 +253,31 @@ final class ActivityController extends AbstractController
{
$view = null;
$activities = [];
// TODO: add pagination
[$person, $accompanyingPeriod] = $this->getEntity($request);
$filter = $this->buildFilterOrder($person ?? $accompanyingPeriod);
$filterArgs = [
'my_activities' => $filter->getSingleCheckboxData('my_activities'),
'types' => $filter->getEntityChoiceData('activity_types'),
'jobs' => $filter->getEntityChoiceData('jobs'),
'before' => $filter->getDateRangeData('activity_date')['to'],
'after' => $filter->getDateRangeData('activity_date')['from'],
];
if ($person instanceof Person) {
$this->denyAccessUnlessGranted(ActivityVoter::SEE, $person);
$count = $this->activityACLAwareRepository->countByPerson($person, ActivityVoter::SEE, $filterArgs);
$paginator = $this->paginatorFactory->create($count);
$activities = $this->activityACLAwareRepository
->findByPerson($person, ActivityVoter::SEE, 0, null, ['date' => 'DESC', 'id' => 'DESC']);
->findByPerson(
$person,
ActivityVoter::SEE,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage(),
['date' => 'DESC', 'id' => 'DESC'],
$filterArgs
);
$event = new PrivacyEvent($person, [
'element_class' => Activity::class,
@@ -308,10 +289,21 @@ final class ActivityController extends AbstractController
} elseif ($accompanyingPeriod instanceof AccompanyingPeriod) {
$this->denyAccessUnlessGranted(ActivityVoter::SEE, $accompanyingPeriod);
$count = $this->activityACLAwareRepository->countByAccompanyingPeriod($accompanyingPeriod, ActivityVoter::SEE, $filterArgs);
$paginator = $this->paginatorFactory->create($count);
$activities = $this->activityACLAwareRepository
->findByAccompanyingPeriod($accompanyingPeriod, ActivityVoter::SEE, 0, null, ['date' => 'DESC', 'id' => 'DESC']);
->findByAccompanyingPeriod(
$accompanyingPeriod,
ActivityVoter::SEE,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage(),
['date' => 'DESC', 'id' => 'DESC'],
$filterArgs
);
$view = 'ChillActivityBundle:Activity:listAccompanyingCourse.html.twig';
} else {
throw new \LogicException("Unsupported");
}
return $this->render(
@@ -320,10 +312,40 @@ final class ActivityController extends AbstractController
'activities' => $activities,
'person' => $person,
'accompanyingCourse' => $accompanyingPeriod,
'filter' => $filter,
'paginator' => $paginator,
]
);
}
private function buildFilterOrder(AccompanyingPeriod|Person $associated): FilterOrderHelper
{
$filterBuilder = $this->filterOrderHelperFactory->create(self::class);
$types = $this->activityACLAwareRepository->findActivityTypeByAssociated($associated);
$jobs = $this->activityACLAwareRepository->findUserJobByAssociated($associated);
$filterBuilder
->addDateRange('activity_date', 'activity.date')
->addSingleCheckbox('my_activities', 'activity_filter.My activities')
->addEntityChoice('activity_types', 'activity_filter.Types', \Chill\ActivityBundle\Entity\ActivityType::class, $types, [
'choice_label' => function (\Chill\ActivityBundle\Entity\ActivityType $activityType) {
$text = match ($activityType->hasCategory()) {
true => $this->translatableStringHelper->localize($activityType->getCategory()->getName()) . ' > ',
false => '',
};
return $text . $this->translatableStringHelper->localize($activityType->getName());
}
])
->addEntityChoice('jobs', 'activity_filter.Jobs', UserJob::class, $jobs, [
'choice_label' => fn (UserJob $u) => $this->translatableStringHelper->localize($u->getLabel())
])
;
return $filterBuilder->build();
}
public function newAction(Request $request): Response
{
$view = null;

View File

@@ -18,67 +18,193 @@ use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\LocationType;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Role\Role;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Security;
use function count;
use function in_array;
final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInterface
final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInterface
{
private AuthorizationHelper $authorizationHelper;
private CenterResolverDispatcherInterface $centerResolverDispatcher;
private EntityManagerInterface $em;
private ActivityRepository $repository;
private Security $security;
private TokenStorageInterface $tokenStorage;
public function __construct(
AuthorizationHelper $authorizationHelper,
CenterResolverDispatcherInterface $centerResolverDispatcher,
TokenStorageInterface $tokenStorage,
ActivityRepository $repository,
EntityManagerInterface $em,
Security $security
private AuthorizationHelperForCurrentUserInterface $authorizationHelper,
private CenterResolverManagerInterface $centerResolverManager,
private ActivityRepository $repository,
private EntityManagerInterface $em,
private Security $security,
private RequestStack $requestStack,
) {
$this->authorizationHelper = $authorizationHelper;
$this->centerResolverDispatcher = $centerResolverDispatcher;
$this->tokenStorage = $tokenStorage;
$this->repository = $repository;
$this->em = $em;
$this->security = $security;
}
public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function countByAccompanyingPeriod(AccompanyingPeriod $period, string $role, array $filters = []): int
{
$user = $this->security->getUser();
$center = $this->centerResolverDispatcher->resolveCenter($period);
$qb = $this->buildBaseQuery($filters);
if (0 === count($orderBy)) {
$orderBy = ['date' => 'DESC'];
$qb
->select('COUNT(a)')
->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $period);
return $qb->getQuery()->getSingleScalarResult();
}
public function countByPerson(Person $person, string $role, array $filters = []): int
{
$qb = $this->buildBaseQuery($filters);
$qb = $this->filterBaseQueryByPerson($qb, $person, $role);
$qb->select('COUNT(a)');
return $qb->getQuery()->getSingleScalarResult();
}
public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array
{
$qb = $this->buildBaseQuery($filters);
$qb->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $period);
foreach ($orderBy as $field => $order) {
$qb->addOrderBy('a.' . $field, $order);
}
$scopes = $this->authorizationHelper
->getReachableCircles($user, $role, $center);
if (null !== $start) {
$qb->setFirstResult($start);
}
if (null !== $limit) {
$qb->setMaxResults($limit);
}
return $this->em->getRepository(Activity::class)
->findByAccompanyingPeriod($period, $scopes, true, $limit, $start, $orderBy);
return $qb->getQuery()->getResult();
}
public function buildBaseQuery(array $filters): QueryBuilder
{
$qb = $this->repository
->createQueryBuilder('a')
;
if (($filters['my_activities'] ?? false) and ($user = $this->security->getUser()) instanceof User) {
$qb->andWhere(
$qb->expr()->orX(
'a.createdBy = :user',
'a.user = :user',
':user MEMBER OF a.users'
)
)->setParameter('user', $user);
}
if ([] !== ($types = $filters['types'] ?? [])) {
$qb->andWhere('a.activityType IN (:types)')->setParameter('types', $types);
}
if ([] !== ($jobs = $filters['jobs'] ?? [])) {
$qb
->leftJoin('a.createdBy', 'creator')
->leftJoin('a.user', 'activity_u')
->andWhere(
$qb->expr()->orX(
'creator.userJob IN (:jobs)',
'activity_u.userJob IN (:jobs)',
'EXISTS (SELECT 1 FROM ' . User::class . ' activity_user WHERE activity_user MEMBER OF a.users AND activity_user.userJob IN (:jobs))'
)
)
->setParameter('jobs', $jobs);
}
if (null !== ($after = $filters['after'] ?? null)) {
$qb->andWhere('a.date >= :after')->setParameter('after', $after);
}
if (null !== ($before = $filters['before'] ?? null)) {
$qb->andWhere('a.date <= :before')->setParameter('before', $before);
}
return $qb;
}
/**
* @param AccompanyingPeriod|Person $associated
* @return array<ActivityType>
*/
public function findActivityTypeByAssociated(AccompanyingPeriod|Person $associated): array
{
$in = $this->em->createQueryBuilder();
$in
->select('1')
->from(Activity::class, 'a');
if ($associated instanceof Person) {
$in = $this->filterBaseQueryByPerson($in, $associated, ActivityVoter::SEE);
} else {
$in->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $associated);
}
// join between the embedded exist query and the main query
$in->andWhere('a.activityType = t');
$qb = $this->em->createQueryBuilder()->setParameters($in->getParameters());
$qb
->select('t')
->from(ActivityType::class, 't')
->where(
$qb->expr()->exists($in->getDQL())
);
return $qb->getQuery()->getResult();
}
public function findUserJobByAssociated(Person|AccompanyingPeriod $associated): array
{
$in = $this->em->createQueryBuilder();
$in->select('IDENTITY(u.userJob)')
->from(User::class, 'u')
->join(
Activity::class,
'a',
Join::WITH,
'a.createdBy = u OR a.user = u OR u MEMBER OF a.users'
);
if ($associated instanceof Person) {
$in = $this->filterBaseQueryByPerson($in, $associated, ActivityVoter::SEE);
} else {
$in->andWhere('a.accompanyingPeriod = :associated');
$in->setParameter('associated', $associated);
}
$qb = $this->em->createQueryBuilder()->setParameters($in->getParameters());
$qb->select('ub', 'JSON_EXTRACT(ub.label, :lang) AS HIDDEN lang')
->from(UserJob::class, 'ub')
->where($qb->expr()->in('ub.id', $in->getDQL()))
->setParameter('lang', $this->requestStack->getCurrentRequest()->getLocale())
->orderBy('lang')
;
return $qb->getQuery()->getResult();
}
public function findByAccompanyingPeriodSimplified(AccompanyingPeriod $period, ?int $limit = 1000): array
{
$rsm = new ResultSetMappingBuilder($this->em);
@@ -159,25 +285,73 @@ final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInte
return $nq->getResult(AbstractQuery::HYDRATE_ARRAY);
}
/**
* @param array $orderBy
*
* @return Activity[]|array
*/
public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array
public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = [], array $filters = []): array
{
$user = $this->security->getUser();
$center = $this->centerResolverDispatcher->resolveCenter($person);
$qb = $this->buildBaseQuery($filters);
if (0 === count($orderBy)) {
$orderBy = ['date' => 'DESC'];
$qb = $this->filterBaseQueryByPerson($qb, $person, $role);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('a.' . $field, $direction);
}
$reachableScopes = $this->authorizationHelper
->getReachableCircles($user, $role, $center);
if (null !== $start) {
$qb->setFirstResult($start);
}
if (null !== $limit) {
$qb->setMaxResults($limit);
}
return $this->em->getRepository(Activity::class)
->findByPersonImplied($person, $reachableScopes, $orderBy, $limit, $start);
return $qb->getQuery()->getResult();
}
private function filterBaseQueryByPerson(QueryBuilder $qb, Person $person, string $role): QueryBuilder
{
$orX = $qb->expr()->orX();
$counter = 0;
foreach ($this->centerResolverManager->resolveCenters($person) as $center) {
$scopes = $this->authorizationHelper->getReachableScopes($role, $center);
if ([] === $scopes) {
continue;
}
$orX->add(sprintf('a.person = :person AND a.scope IN (:scopes_%d)', $counter));
$qb->setParameter(sprintf('scopes_%d', $counter), $scopes);
$qb->setParameter('person', $person);
$counter++;
}
foreach ($person->getAccompanyingPeriodParticipations() as $participation) {
if (!$this->security->isGranted(ActivityVoter::SEE, $participation->getAccompanyingPeriod())) {
continue;
}
$and = $qb->expr()->andX(
sprintf('a.accompanyingPeriod = :period_%d', $counter),
sprintf('a.date >= :participation_start_%d', $counter)
);
$qb
->setParameter(sprintf('period_%d', $counter), $participation->getAccompanyingPeriod())
->setParameter(sprintf('participation_start_%d', $counter), $participation->getStartDate());
if (null !== $participation->getEndDate()) {
$and->add(sprintf('a.date < :participation_end_%d', $counter));
$qb
->setParameter(sprintf('participation_end_%d', $counter), $participation->getEndDate());
}
$orX->add($and);
$counter++;
}
if (0 === $orX->count()) {
$qb->andWhere('FALSE = TRUE');
} else {
$qb->andWhere($orX);
}
return $qb;
}
public function queryTimelineIndexer(string $context, array $args = []): array
@@ -226,7 +400,6 @@ final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInte
// acls:
$reachableCenters = $this->authorizationHelper->getReachableCenters(
$this->tokenStorage->getToken()->getUser(),
ActivityVoter::SEE
);
@@ -251,7 +424,7 @@ final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInte
continue;
}
// we get all the reachable scopes for this center
$reachableScopes = $this->authorizationHelper->getReachableScopes($this->tokenStorage->getToken()->getUser(), ActivityVoter::SEE, $center);
$reachableScopes = $this->authorizationHelper->getReachableScopes(ActivityVoter::SEE, $center);
// we get the ids for those scopes
$reachablesScopesId = array_map(
static fn (Scope $scope) => $scope->getId(),

View File

@@ -11,15 +11,32 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Repository;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Entity\ActivityType;
use Chill\MainBundle\Entity\UserJob;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
interface ActivityACLAwareRepositoryInterface
{
/**
* @return Activity[]|array
* Return all the activities associated to an accompanying period and that the user is allowed to apply the given role.
*
*
* @param array{my_activities?: bool, types?: array<ActivityType>, jobs?: array<UserJob>, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters
* @return array<Activity>
*/
public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array;
public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array;
/**
* @param array{my_activities?: bool, types?: array<ActivityType>, jobs?: array<UserJob>, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters
*/
public function countByAccompanyingPeriod(AccompanyingPeriod $period, string $role, array $filters = []): int;
/**
* @param array{my_activities?: bool, types?: array<ActivityType>, jobs?: array<UserJob>, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters
*/
public function countByPerson(Person $person, string $role, array $filters = []): int;
/**
* Return a list of activities, simplified as array (not object).
@@ -31,7 +48,28 @@ interface ActivityACLAwareRepositoryInterface
public function findByAccompanyingPeriodSimplified(AccompanyingPeriod $period, ?int $limit = 1000): array;
/**
* @return Activity[]|array
* @param array{my_activities?: bool, types?: array<ActivityType>, jobs?: array<UserJob>, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters
* @return array<Activity>
*/
public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array;
public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array;
/**
* Return a list of the type for the activities associated to person or accompanying period
*
* @return array<ActivityType>
*/
public function findActivityTypeByAssociated(AccompanyingPeriod|Person $associated): array;
/**
* Return a list of the user job for the activities associated to person or accompanying period
*
* Associated mean the job:
* - of the creator;
* - of the user (activity.user)
* - of all the users
*
* @return array<UserJob>
*/
public function findUserJobByAssociated(AccompanyingPeriod|Person $associated): array;
}

View File

@@ -11,9 +11,13 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Repository;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Entity\ActivityType;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
final class ActivityTypeRepository implements ActivityTypeRepositoryInterface
{

View File

@@ -12,12 +12,14 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Repository;
use Chill\ActivityBundle\Entity\ActivityType;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\Persistence\ObjectRepository;
interface ActivityTypeRepositoryInterface extends ObjectRepository
{
/**
* @return array|ActivityType[]
* @return array<ActivityType>
*/
public function findAllActive(): array;
}

View File

@@ -80,12 +80,15 @@
<div class="context-{{ context }}">
{{ filter|chill_render_filter_order_helper }}
{% if activities|length == 0 %}
<p class="chill-no-data-statement">
{{ "There isn't any activities."|trans }}
</p>
{% else %}
<div class="flex-table activity-list">
{% for activity in activities %}
{% include 'ChillActivityBundle:Activity:_list_item.html.twig' with {
@@ -96,4 +99,6 @@
</div>
{% endif %}
{{ chill_pagination(paginator) }}
</div>

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Tests\Repository;
use Chill\ActivityBundle\Entity\ActivityType;
use Chill\ActivityBundle\Repository\ActivityACLAwareRepository;
use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class ActivityACLAwareRepositoryTest extends KernelTestCase
{
use ProphecyTrait;
private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser;
private CenterResolverManagerInterface $centerResolverManager;
private ActivityRepository $activityRepository;
private EntityManagerInterface $entityManager;
private Security $security;
private RequestStack $requestStack;
protected function setUp(): void
{
self::bootKernel();
$this->authorizationHelperForCurrentUser = self::$container->get(AuthorizationHelperForCurrentUserInterface::class);
$this->centerResolverManager = self::$container->get(CenterResolverManagerInterface::class);
$this->activityRepository = self::$container->get(ActivityRepository::class);
$this->entityManager = self::$container->get(EntityManagerInterface::class);
$this->security = self::$container->get(Security::class);
$this->requestStack = $requestStack = new RequestStack();
$request = $this->prophesize(Request::class);
$request->getLocale()->willReturn('fr');
$request->getDefaultLocale()->willReturn('fr');
$requestStack->push($request->reveal());
}
/**
* @dataProvider provideDataFindByAccompanyingPeriod
*/
public function testFindByAccompanyingPeriod(AccompanyingPeriod $period, User $user, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): void
{
$security = $this->prophesize(Security::class);
$security->isGranted($role, $period)->willReturn(true);
$security->getUser()->willReturn($user);
$repository = new ActivityACLAwareRepository(
$this->authorizationHelperForCurrentUser,
$this->centerResolverManager,
$this->activityRepository,
$this->entityManager,
$security->reveal(),
$this->requestStack
);
$actual = $repository->findByAccompanyingPeriod($period, $role, $start, $limit, $orderBy, $filters);
self::assertIsArray($actual);
}
/**
* @dataProvider provideDataFindByAccompanyingPeriod
*/
public function testFindActivityTypeByAccompanyingPeriod(AccompanyingPeriod $period, User $user, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): void
{
$security = $this->prophesize(Security::class);
$security->isGranted($role, $period)->willReturn(true);
$security->getUser()->willReturn($user);
$repository = new ActivityACLAwareRepository(
$this->authorizationHelperForCurrentUser,
$this->centerResolverManager,
$this->activityRepository,
$this->entityManager,
$security->reveal(),
$this->requestStack
);
$actual = $repository->findActivityTypeByAssociated($period);
self::assertIsArray($actual);
}
/**
* @dataProvider provideDataFindByPerson
*/
public function testFindActivityTypeByPerson(Person $person, User $user, array $centers, array $scopes, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = [], array $filters = []): void
{
$role = ActivityVoter::SEE;
$centerResolver = $this->prophesize(CenterResolverManagerInterface::class);
$centerResolver->resolveCenters($person)->willReturn($centers);
$authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$authorizationHelper->getReachableScopes($role, Argument::type(Center::class))
->willReturn($scopes);
$security = $this->prophesize(Security::class);
$security->isGranted($role, Argument::type(AccompanyingPeriod::class))->willReturn(true);
$security->getUser()->willReturn($user);
$repository = new ActivityACLAwareRepository(
$authorizationHelper->reveal(),
$centerResolver->reveal(),
$this->activityRepository,
$this->entityManager,
$security->reveal(),
$this->requestStack
);
$actual = $repository->findByPerson($person, $role, $start, $limit, $orderBy, $filters);
self::assertIsArray($actual);
}
/**
* @dataProvider provideDataFindByPerson
*/
public function testFindByPerson(Person $person, User $user, array $centers, array $scopes, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = [], array $filters = []): void
{
$centerResolver = $this->prophesize(CenterResolverManagerInterface::class);
$centerResolver->resolveCenters($person)->willReturn($centers);
$authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$authorizationHelper->getReachableScopes($role, Argument::type(Center::class))
->willReturn($scopes);
$security = $this->prophesize(Security::class);
$security->isGranted($role, Argument::type(AccompanyingPeriod::class))->willReturn(true);
$security->getUser()->willReturn($user);
$repository = new ActivityACLAwareRepository(
$authorizationHelper->reveal(),
$centerResolver->reveal(),
$this->activityRepository,
$this->entityManager,
$security->reveal(),
$this->requestStack
);
$actual = $repository->findByPerson($person, $role, $start, $limit, $orderBy, $filters);
self::assertIsArray($actual);
}
public function provideDataFindByPerson(): iterable
{
$this->setUp();
/** @var Person $person */
if (null === $person = $this->entityManager->createQueryBuilder()
->select('p')->from(Person::class, 'p')->setMaxResults(1)
->getQuery()->getSingleResult()) {
throw new \RuntimeException("person not found");
}
/** @var AccompanyingPeriod $period1 */
if (null === $period1 = $this->entityManager
->createQueryBuilder()
->select('a')
->from(AccompanyingPeriod::class, 'a')
->setMaxResults(1)
->getQuery()
->getSingleResult()) {
throw new \RuntimeException("no period found");
}
/** @var AccompanyingPeriod $period2 */
if (null === $period2 = $this->entityManager
->createQueryBuilder()
->select('a')
->from(AccompanyingPeriod::class, 'a')
->where('a.id > :pid')
->setParameter('pid', $period1->getId())
->setMaxResults(1)
->getQuery()
->getSingleResult()) {
throw new \RuntimeException("no second period found");
}
// add a period
$period1->addPerson($person);
$period2->addPerson($person);
$period1->getParticipationsContainsPerson($person)->first()->setEndDate(
(new \DateTime('now'))->add(new \DateInterval('P1M'))
);
if ([] === $types = $this->entityManager
->createQueryBuilder()
->select('t')
->from(ActivityType::class, 't')
->setMaxResults(2)
->getQuery()
->getResult()) {
throw new \RuntimeException("no types");
}
if ([] === $jobs = $this->entityManager
->createQueryBuilder()
->select('j')
->from(UserJob::class, 'j')
->setMaxResults(2)
->getQuery()
->getResult()
) {
throw new \RuntimeException("no jobs found");
}
if (null === $user = $this->entityManager
->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->setMaxResults(1)
->getQuery()
->getSingleResult()
) {
throw new \RuntimeException("no user found");
}
if ([] === $centers = $this->entityManager->createQueryBuilder()
->select('c')->from(Center::class, 'c')->setMaxResults(2)->getQuery()
->getResult()) {
throw new \RuntimeException("no centers found");
}
if ([] === $scopes = $this->entityManager->createQueryBuilder()
->select('s')->from(Scope::class, 's')->setMaxResults(2)->getQuery()
->getResult()) {
throw new \RuntimeException("no scopes found");
}
yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], []];
yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['my_activities' => true]];
yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['types' => $types]];
yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['jobs' => $jobs]];
yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['after' => new \DateTimeImmutable('1 year ago')]];
yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['before' => new \DateTimeImmutable('1 year ago')]];
yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['after' => new \DateTimeImmutable('1 year ago'), 'before' => new \DateTimeImmutable('1 month ago')]];
}
public function provideDataFindByAccompanyingPeriod(): iterable
{
$this->setUp();
if (null === $period = $this->entityManager
->createQueryBuilder()
->select('a')
->from(AccompanyingPeriod::class, 'a')
->setMaxResults(1)
->getQuery()
->getSingleResult()) {
throw new \RuntimeException("no period found");
}
if ([] === $types = $this->entityManager
->createQueryBuilder()
->select('t')
->from(ActivityType::class, 't')
->setMaxResults(2)
->getQuery()
->getResult()) {
throw new \RuntimeException("no types");
}
if ([] === $jobs = $this->entityManager
->createQueryBuilder()
->select('j')
->from(UserJob::class, 'j')
->setMaxResults(2)
->getQuery()
->getResult()
) {
throw new \RuntimeException("no jobs found");
}
if (null === $user = $this->entityManager
->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->setMaxResults(1)
->getQuery()
->getSingleResult()
) {
throw new \RuntimeException("no user found");
}
yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], []];
yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['my_activities' => true]];
yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['types' => $types]];
yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['jobs' => $jobs]];
yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['after' => new \DateTimeImmutable('1 year ago')]];
yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['before' => new \DateTimeImmutable('1 year ago')]];
yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['after' => new \DateTimeImmutable('1 year ago'), 'before' => new \DateTimeImmutable('1 month ago')]];
}
}

View File

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

View File

@@ -83,12 +83,23 @@ Third persons: Tiers non-pro.
Others persons: Usagers
Third parties: Tiers professionnels
Users concerned: T(M)S
activity:
date: Date de l'échange
Insert a document: Insérer un document
Remove a document: Supprimer le document
comment: Commentaire
No documents: Aucun document
# activity filter in list page
activity_filter:
My activities: Mes échanges (où j'interviens)
Types: Par type d'échange
Jobs: Par métier impliqué
By: Filtrer par
Search: Chercher dans la liste
By date: Filtrer par date
#timeline
'%user% has done an %activity_type%': '%user% a effectué un échange de type "%activity_type%"'

View File

@@ -176,11 +176,12 @@ export:
agent_id: Utilisateur
creator_id: Créateur
main_scope: Service principal de l'utilisateur
main_center: Centre principal de l'utilisteur
main_center: Centre principal de l'utilisateur
aside_activity_type: Catégorie d'activité annexe
date: Date
duration: Durée
note: Note
id: Identifiant
Exports of aside activities: Exports des activités annexes
Count aside activities: Nombre d'activités annexes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,9 @@ final class PickCenterType extends AbstractType
$export->requiredRole()
);
// order alphabetically
usort($centers, fn (Center $a, Center $b) => $a->getCenter() <=> $b->getName());
$builder->add('center', EntityType::class, [
'class' => Center::class,
'choices' => $centers,

View File

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

View File

@@ -42,3 +42,15 @@ form {
font-weight: 700;
margin-bottom: .375em;
}
.chill_filter_order {
background: $gray-100; /*
border: 3px dashed $white;
background: repeating-linear-gradient(
-45deg,
$gray-100,
$gray-100 2px,
$white 2px,
$white 6px
); */
}

View File

@@ -1,65 +1,110 @@
{{ form_start(form) }}
<div class="chill_filter_order container my-4">
<div class="row">
{% set btnSubmit = 0 %}
<div class="chill_filter_order container-xxl p-5 py-2 my-3">
<div class="row my-2">
{% if form.vars.has_search_box %}
<div class="col-md-12">
<div class="input-group mb-3">
{{ form_widget(form.q)}}
<button type="submit" class="btn btn-chill-l-gray"><i class="fa fa-search"></i></button>
<div class="col-sm-12">
<div class="input-group">
{{ form_widget(form.q) }}
<button type="submit" class="btn btn-misc"><i class="fa fa-search"></i></button>
</div>
</div>
{% endif %}
</div>
{% if form.dateRanges is defined %}
{% set btnSubmit = 1 %}
{% if form.dateRanges|length > 0 %}
{% for dateRangeName, _o in form.dateRanges %}
<div class="row gx-2 justify-content-center">
<div class="row my-2">
{% if form.dateRanges[dateRangeName].vars.label is not same as(false) %}
<div class="col-md-5">
{{ form_label(form.dateRanges[dateRangeName])}}
</div>
{% else %}
<div class="col-sm-4 col-form-label">{{ 'activity_filter.By date'|trans }}</div>
{% endif %}
<div class="col-md-6">
<div class="input-group mb-3">
<div class="col-sm-8 pt-1">
<div class="input-group">
<span class="input-group-text">{{ 'chill_calendar.From'|trans }}</span>
{{ form_widget(form.dateRanges[dateRangeName]['from']) }}
<span class="input-group-text">{{ 'chill_calendar.To'|trans }}</span>
{{ form_widget(form.dateRanges[dateRangeName]['to']) }}
</div>
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-misc"><i class="fa fa-filter"></i></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% if form.checkboxes is defined %}
{% set btnSubmit = 1 %}
{% if form.checkboxes|length > 0 %}
{% for checkbox_name, options in form.checkboxes %}
<div class="row gx-0">
<div class="col-md-12">
<div class="row my-2">
<div class="col-sm-4 col-form-label">{{ 'activity_filter.By'|trans }}</div>
<div class="col-sm-8 pt-2">
{% for c in form['checkboxes'][checkbox_name].children %}
<div class="form-check form-check-inline">
{{ form_widget(c) }}
{{ form_label(c) }}
</div>
{{ form_widget(c) }}
{{ form_label(c) }}
{% endfor %}
</div>
</div>
{% if loop.last %}
<div class="row gx-0">
<div class="col-md-12">
<ul class="record_actions">
<li>
<button type="submit" class="btn btn-misc"><i class="fa fa-filter"></i></button>
</li>
</ul>
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% if form.entity_choices is defined %}
{% set btnSubmit = 1 %}
{% if form.entity_choices |length > 0 %}
{% for checkbox_name, options in form.entity_choices %}
<div class="row my-2">
{% if form.entity_choices[checkbox_name].vars.label is not same as(false) %}
{{ form_label(form.entity_choices[checkbox_name])}}
{% endif %}
<div class="col-sm-8 pt-2">
{% for c in form['entity_choices'][checkbox_name].children %}
{{ form_widget(c) }}
{{ form_label(c) }}
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% if form.user_pickers is defined %}
{% set btnSubmit = 1 %}
{% if form.user_pickers.children|length > 0 %}
{% for name, options in form.user_pickers %}
<div class="row my-2">
{% if form.user_pickers[name].vars.label is not same as(false) %}
{{ form_label(form.user_pickers[name]) }}
{% else %}
{{ form_label(form.user_pickers[name].vars.label) }}
{% endif %}
<div class="col-sm-8 pt-2">
{{ form_widget(form.user_pickers[name]) }}
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% if form.single_checkboxes is defined %}
{% set btnSubmit = 1 %}
{% for name, _o in form.single_checkboxes %}
<div class="row my-2">
<div class="col-sm-4 col-form-label">{{ 'activity_filter.By'|trans }}</div>
<div class="col-sm-8 pt-2">
{{ form_widget(form.single_checkboxes[name]) }}
</div>
</div>
{% endfor %}
{% endif %}
{% if btnSubmit == 1 %}
<div class="row my-2">
<button type="submit" class="btn btn-sm btn-misc"><i class="fa fa-fw fa-filter"></i>{{ 'Filter'|trans }}</button>
</div>
{% endif %}
</div>
{% for k,v in otherParameters %}
<input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endfor %}
{{ form_end(form) }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ namespace Chill\MainBundle\Templating\Listing;
use Chill\MainBundle\Form\Type\Listing\FilterOrderType;
use DateTimeImmutable;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -24,11 +26,16 @@ class FilterOrderHelper
{
private array $checkboxes = [];
/**
* @var array<string, array{label: string}>
*/
private array $singleCheckbox = [];
private array $dateRanges = [];
private FormFactoryInterface $formFactory;
private ?string $formName = 'f';
public const FORM_NAME = 'f';
private array $formOptions = [];
@@ -40,6 +47,16 @@ class FilterOrderHelper
private ?array $submitted = null;
/**
* @var array<string, array{label: string, choices: array, options: array}>
*/
private array $entityChoices = [];
/**
* @var array<string, array{label: string, options: array}>
*/
private array $userPickers = [];
public function __construct(
FormFactoryInterface $formFactory,
RequestStack $requestStack
@@ -48,7 +65,37 @@ class FilterOrderHelper
$this->requestStack = $requestStack;
}
public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = []): self
public function addSingleCheckbox(string $name, string $label): self
{
$this->singleCheckbox[$name] = ['label' => $label];
return $this;
}
/**
* @param class-string $class
*/
public function addEntityChoice(string $name, string $class, string $label, array $choices, array $options = []): self
{
$this->entityChoices[$name] = ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options];
return $this;
}
public function getEntityChoices(): array
{
return $this->entityChoices;
}
public function addUserPicker(string $name, ?string $label = null, array $options = []): self
{
$this->userPickers[$name] = ['label' => $label, 'options' => $options];
return $this;
}
public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = [], array $options = []): self
{
$missing = count($choices) - count($trans) - 1;
$this->checkboxes[$name] = [
@@ -58,6 +105,7 @@ class FilterOrderHelper
0 < $missing ?
array_fill(0, $missing, null) : []
),
...$options,
];
return $this;
@@ -73,7 +121,7 @@ class FilterOrderHelper
public function buildForm(): FormInterface
{
return $this->formFactory
->createNamed($this->formName, $this->formType, $this->getDefaultData(), array_merge([
->createNamed(self::FORM_NAME, $this->formType, $this->getDefaultData(), array_merge([
'helper' => $this,
'method' => 'GET',
'csrf_protection' => false,
@@ -81,11 +129,31 @@ class FilterOrderHelper
->handleRequest($this->requestStack->getCurrentRequest());
}
public function getUserPickers(): array
{
return $this->userPickers;
}
public function getUserPickerData(string $name): array
{
return $this->getFormData()['user_pickers'][$name];
}
public function getCheckboxData(string $name): array
{
return $this->getFormData()['checkboxes'][$name];
}
public function getSingleCheckboxData(string $name): ?bool
{
return $this->getFormData()['single_checkboxes'][$name];
}
public function getEntityChoiceData($name): mixed
{
return $this->getFormData()['entity_choices'][$name];
}
public function getCheckboxes(): array
{
return $this->checkboxes;
@@ -97,7 +165,15 @@ class FilterOrderHelper
}
/**
* @return array<'to': DateTimeImmutable, 'from': DateTimeImmutable>
* @return array<string, array{label: string}>
*/
public function getSingleCheckbox(): array
{
return $this->singleCheckbox;
}
/**
* @return array{to: ?DateTimeImmutable, from: ?DateTimeImmutable}
*/
public function getDateRangeData(string $name): array
{
@@ -128,7 +204,13 @@ class FilterOrderHelper
private function getDefaultData(): array
{
$r = [];
$r = [
'checkboxes' => [],
'dateRanges' => [],
'single_checkboxes' => [],
'entity_choices' => [],
'user_pickers' => []
];
if ($this->hasSearchBox()) {
$r['q'] = '';
@@ -143,7 +225,20 @@ class FilterOrderHelper
$r['dateRanges'][$name]['to'] = $defaults['to'];
}
foreach ($this->singleCheckbox as $name => $c) {
$r['single_checkboxes'][$name] = false;
}
foreach ($this->entityChoices as $name => $c) {
$r['entity_choices'][$name] = ($c['options']['multiple'] ?? true) ? [] : null;
}
foreach ($this->userPickers as $name => $u) {
$r['user_pickers'][$name] = ($u['options']['multiple'] ?? true) ? [] : null;
}
return $r;
}
private function getFormData(): array

View File

@@ -27,6 +27,21 @@ class FilterOrderHelperBuilder
private ?array $searchBoxFields = null;
/**
* @var array<string, array{label: string}>
*/
private array $singleCheckboxes = [];
/**
* @var array<string, array{label: string, class: class-string, choices: array, options: array}>
*/
private array $entityChoices = [];
/**
* @var array<string, array{label: string, options: array}>
*/
private array $userPickers = [];
public function __construct(
FormFactoryInterface $formFactory,
RequestStack $requestStack
@@ -35,6 +50,13 @@ class FilterOrderHelperBuilder
$this->requestStack = $requestStack;
}
public function addSingleCheckbox(string $name, string $label): self
{
$this->singleCheckboxes[$name] = ['label' => $label];
return $this;
}
public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = []): self
{
$this->checkboxes[$name] = ['choices' => $choices, 'default' => $default, 'trans' => $trans];
@@ -42,6 +64,16 @@ class FilterOrderHelperBuilder
return $this;
}
/**
* @param class-string $class
*/
public function addEntityChoice(string $name, string $label, string $class, array $choices, ?array $options = []): self
{
$this->entityChoices[$name] = ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options];
return $this;
}
public function addDateRange(string $name, ?string $label = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null): self
{
$this->dateRanges[$name] = ['from' => $from, 'to' => $to, 'label' => $label];
@@ -56,6 +88,13 @@ class FilterOrderHelperBuilder
return $this;
}
public function addUserPicker(string $name, ?string $label = null, ?array $options = []): self
{
$this->userPickers[$name] = ['label' => $label, 'options' => $options];
return $this;
}
public function build(): FilterOrderHelper
{
$helper = new FilterOrderHelper(
@@ -75,6 +114,18 @@ class FilterOrderHelperBuilder
$helper->addCheckbox($name, $choices, $default, $trans);
}
foreach (
$this->singleCheckboxes as $name => ['label' => $label]
) {
$helper->addSingleCheckbox($name, $label);
}
foreach (
$this->entityChoices as $name => ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options]
) {
$helper->addEntityChoice($name, $class, $label, $choices, $options);
}
foreach (
$this->dateRanges as $name => [
'from' => $from,
@@ -85,6 +136,17 @@ class FilterOrderHelperBuilder
$helper->addDateRange($name, $label, $from, $to);
}
foreach (
$this->userPickers as $name => [
'label' => $label,
'options' => $options
]
) {
$helper->addUserPicker($name, $label, $options);
}
return $helper;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -786,7 +786,7 @@ class AccompanyingPeriod implements
}
/**
* @return Collection|AccompanyingPeriodLocationHistory[]
* @return Collection<AccompanyingPeriodLocationHistory>
*/
public function getLocationHistories(): Collection
{
@@ -797,6 +797,7 @@ class AccompanyingPeriod implements
* Get where the location is.
*
* @Groups({"read"})
* @return 'person'|'address'|'none'
*/
public function getLocationStatus(): string
{
@@ -1209,6 +1210,7 @@ class AccompanyingPeriod implements
$this->addressLocation = $addressLocation;
if (null !== $addressLocation) {
$this->setPersonLocation(null);
$locationHistory = new AccompanyingPeriodLocationHistory();
$locationHistory
->setStartDate(new DateTimeImmutable('now'))
@@ -1327,6 +1329,7 @@ class AccompanyingPeriod implements
$this->personLocation = $person;
if (null !== $person) {
$this->setAddressLocation(null);
$locationHistory = new AccompanyingPeriodLocationHistory();
$locationHistory
->setStartDate(new DateTimeImmutable('now'))

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

@@ -13,20 +13,18 @@ namespace Chill\PersonBundle\Export\Aggregator\PersonAggregators;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Export\ExportElementValidatedInterface;
use DateTime;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class AgeAggregator implements AggregatorInterface, ExportElementValidatedInterface
final readonly class AgeAggregator implements AggregatorInterface, ExportElementValidatedInterface
{
private TranslatorInterface $translator;
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
public function __construct(
private RollingDateConverterInterface $rollingDateConverter,
) {
}
public function addRole(): ?string
@@ -37,7 +35,7 @@ final class AgeAggregator implements AggregatorInterface, ExportElementValidated
public function alterQuery(QueryBuilder $qb, $data)
{
$qb->addSelect('DATE_DIFF(:date_age_calculation, person.birthdate)/365 as person_age');
$qb->setParameter('date_age_calculation', $data['date_age_calculation']);
$qb->setParameter('date_age_calculation', $this->rollingDateConverter->convert($data['date_age_calculation']));
$qb->addGroupBy('person_age');
}
@@ -48,16 +46,13 @@ final class AgeAggregator implements AggregatorInterface, ExportElementValidated
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('date_age_calculation', DateType::class, [
$builder->add('date_age_calculation', PickRollingDateType::class, [
'label' => 'Calculate age in relation to this date',
'attr' => ['class' => 'datepicker'],
'widget' => 'single_text',
'format' => 'dd-MM-yyyy',
]);
}
public function getFormDefaultData(): array
{
return ['date_age_calculation' => new DateTime()];
return ['date_age_calculation' => new RollingDate(RollingDate::T_TODAY)];
}
public function getLabels($key, array $values, $data)
@@ -67,11 +62,7 @@ final class AgeAggregator implements AggregatorInterface, ExportElementValidated
return 'Age';
}
if (null === $value) {
return $this->translator->trans('without data');
}
return $value;
return $value ?? '';
};
}

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

@@ -101,7 +101,6 @@ class CountAccompanyingCourse implements ExportInterface, GroupedExportInterface
->andWhere('acp.step != :count_acp_step')
->leftJoin('acp.participations', 'acppart')
->leftJoin('acppart.person', 'person')
->andWhere('acppart.startDate != acppart.endDate OR acppart.endDate IS NULL')
->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM ' . PersonCenterHistory::class . ' acl_count_person_history WHERE acl_count_person_history.person = person

View File

@@ -101,7 +101,6 @@ class CountAccompanyingPeriodWork implements ExportInterface, GroupedExportInter
->join('acpw.accompanyingPeriod', 'acp')
->join('acp.participations', 'acppart')
->join('acppart.person', 'person')
->andWhere('acppart.startDate != acppart.endDate OR acppart.endDate IS NULL')
->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM ' . PersonCenterHistory::class . ' acl_count_person_history WHERE acl_count_person_history.person = person

View File

@@ -101,7 +101,6 @@ class CountEvaluation implements ExportInterface, GroupedExportInterface
->join('acpw.accompanyingPeriod', 'acp')
->join('acp.participations', 'acppart')
->join('acppart.person', 'person')
->andWhere('acppart.startDate != acppart.endDate OR acppart.endDate IS NULL')
->andWhere(
$qb->expr()->exists(
'SELECT 1 FROM ' . PersonCenterHistory::class . ' acl_count_person_history WHERE acl_count_person_history.person = person

View File

@@ -416,15 +416,11 @@ class ListAccompanyingPeriod implements ListInterface, GroupedExportInterface
)
)
)
->leftJoin(PersonHouseholdAddress::class, 'personAddress', Join::WITH, 'locationHistory.personLocation = personAddress.person')
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('personAddress'),
$qb->expr()->andX(
$qb->expr()->lte('personAddress.validFrom', ':calcDate'),
$qb->expr()->orX($qb->expr()->isNull('personAddress.validTo'), $qb->expr()->gt('personAddress.validTo', ':calcDate'))
)
)
->leftJoin(
PersonHouseholdAddress::class,
'personAddress',
Join::WITH,
'locationHistory.personLocation = personAddress.person AND (personAddress.validFrom <= :calcDate AND (personAddress.validTo IS NULL OR personAddress.validTo > :calcDate))'
)
->leftJoin(Address::class, 'acp_address', Join::WITH, 'COALESCE(IDENTITY(locationHistory.addressLocation), IDENTITY(personAddress.address)) = acp_address.id');

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,11 +44,11 @@ class CurrentActionFilter implements FilterInterface
public function describeAction($data, $format = 'string'): array
{
return ['Filtered by current action'];
return ['Filtered actions without end date'];
}
public function getTitle(): string
{
return 'Filter by current actions';
return 'Filter actions without end date';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -138,12 +138,14 @@ final class AccompanyingPeriodTest extends \PHPUnit\Framework\TestCase
$this->assertCount(1, $period->getLocationHistories());
$this->assertSame($address, $period->getLocationHistories()->first()->getAddressLocation());
$this->assertNull($period->getLocationHistories()->first()->getPersonLocation());
$period->setPersonLocation($person);
$period->setAddressLocation(null);
$this->assertCount(2, $period->getLocationHistories());
$this->assertSame($person, $period->getLocationHistories()->last()->getPersonLocation());
$this->assertNull($period->getLocationHistories()->last()->getAddressLocation());
$period->setAddressLocation($address);
$period->setPersonLocation(null);
@@ -172,6 +174,27 @@ final class AccompanyingPeriodTest extends \PHPUnit\Framework\TestCase
} while ($iterator->valid());
}
public function testHistoryLocationNotHavingBothAtStart()
{
$period = new AccompanyingPeriod();
$person = new Person();
$address = new Address();
$period->setAddressLocation($address);
$period->setPersonLocation($person);
$period->setStep(AccompanyingPeriod::STEP_CONFIRMED);
$this->assertCount(1, $period->getLocationHistories());
self::assertNull($period->getAddressLocation());
self::assertNull($period->getLocationHistories()->first()->getAddressLocation());
self::assertSame($person, $period->getLocationHistories()->first()->getPersonLocation());
self::assertSame($person, $period->getPersonLocation());
self::assertEquals('person', $period->getLocationStatus());
}
public function testIsClosed()
{
$period = new AccompanyingPeriod(new DateTime());

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230628152138 extends AbstractMigration
{
public function getDescription(): string
{
return 'An accompanying period cannot have both a locaiton on a period and on an address';
}
public function up(Schema $schema): void
{
$this->addSql('UPDATE chill_person_accompanying_period SET addresslocation_id = NULL
where personlocation_id IS NOT NULL AND addresslocation_id IS NOT NULL');
$this->addSql('INSERT INTO chill_person_accompanying_period_location_history
(id, period_id, startdate, enddate, createdat, personlocation_id, addresslocation_id, createdby_id)
SELECT nextval(\'chill_person_accompanying_period_location_history_id_seq\'), period_id, startdate, startdate, now(), null, addresslocation_id, null
FROM chill_person_accompanying_period_location_history
WHERE personlocation_id IS NOT NULL AND addresslocation_id IS NOT NULL
');
$this->addSql('UPDATE chill_person_accompanying_period_location_history SET addresslocation_id = NULL WHERE addresslocation_id IS NOT NULL AND personlocation_id IS NOT NULL');
$this->addSql('ALTER TABLE chill_person_accompanying_period ADD CONSTRAINT location_check CHECK (personlocation_id IS NULL OR addresslocation_id IS NULL)');
$this->addSql('ALTER TABLE chill_person_accompanying_period_location_history ADD CONSTRAINT location_check CHECK (personlocation_id IS NULL OR addresslocation_id IS NULL)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_accompanying_period DROP CONSTRAINT location_check');
$this->addSql('ALTER TABLE chill_person_accompanying_period_location_history DROP CONSTRAINT location_check');
}
}

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_FULL: Voir les détails, créer, supprimer et mettre à jour un parcours d'accompagnement
CHILL_PERSON_ACCOMPANYING_COURSE_REASSIGN_BULK: Réassigner les parcours en lot
CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_DETAILS: Voir les détails d'un parcours d'accompagnement
CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_DETAILS: Voir les détails d'un parcours d'accompagnement
CHILL_PERSON_ACCOMPANYING_PERIOD_STATS: Statistiques sur les parcours d'accompagnement
CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_CONFIDENTIAL: Voir les parcours confidentiels
CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_CREATE: Créer une action d'accompagnement
CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_DELETE: Supprimer une action d'accompagnement
@@ -372,7 +373,7 @@ Count people participating in an accompanying course by various parameters.: Com
Exports of accompanying courses: Exports des parcours d'accompagnement
Count accompanying courses: Nombre de parcours
Count accompanying courses by various parameters: Compte le nombre de parcours en fonction de différents filtres.
Accompanying courses participation duration and number of participations: Durée moyenne et nombre des participation des usagers aux parcours
Accompanying courses participation duration and number of participations: Durée moyenne et nombre des participations des usagers aux parcours
Create an average of accompanying courses duration of each person participation to accompanying course, according to filters on persons, accompanying course: Crée un rapport qui comptabilise la moyenne de la durée de participation de chaque usager concerné aux parcours, avec différents filtres, notamment sur les usagers concernés.
Closingdate to apply: Date de fin à prendre en compte lorsque le parcours n'est pas clotûré
@@ -585,8 +586,8 @@ Filter by creator job: Filtrer les parcours par métier du créateur
'Filtered by creator job: only %jobs%': 'Filtré par métier du créateur: uniquement %jobs%'
Group by creator job: Grouper les parcours par métier du créateur
Filter by current actions: Filtrer les actions en cours
Filtered by current action: 'Filtré: uniquement les actions en cours (sans date de fin)'
Filter actions without end date: Filtre les actions sans date de fin (ouvertes)
Filtered actions without end date: 'Filtré: uniquement les actions sans date de fin (ouvertes)'
Filter by start date evaluations: Filtrer les évaluations par date de début
Filter by end date evaluations: Filtrer les évaluations par date de fin
start period date: Date de début de la période
@@ -1016,6 +1017,11 @@ export:
Household composition: Composition du ménage
Group course by household composition: Grouper les usagers par composition familiale
Calc date: Date de calcul de la composition du ménage
by_center:
title: Grouper les usagers par centre
at_date: Date de calcul du centre
center: Centre de l'usager
course:
by_referrer:
Computation date for referrer: Date à laquelle le référent était actif
@@ -1032,6 +1038,15 @@ export:
Number of actions: Nombre d'actions
by_creator_job:
Creator's job: Métier du créateur
by_user_working:
title: Grouper les parcours par intervenant
user: Intervenant
by_job_working:
title: Grouper les parcours par métier de l'intervenant
job: Métier de l'intervenant
by_scope_working:
title: Grouper les parcours par service de l'intervenant
scope: Service de l'intervenant
course_work:
by_current_action:
Current action ?: Action en cours ?
@@ -1081,8 +1096,20 @@ export:
end_date: Fin de la période
Only course with events between %startDate% and %endDate%: Seulement les parcours ayant reçu une intervention entre le %startDate% et le %endDate%
by_user_working:
title: Filter les parcours par intervenant
'Filtered by user working on course: only %users%': 'Filtré par intervenants sur le parcours: seulement %users%'
title: Filter les parcours par intervenant, entre deux dates
'Filtered by user working on course: only %users%, between %start_date% and %end_date%': 'Filtré par intervenants sur le parcours: seulement %users%, entre le %start_date% et le %end_date%'
User working after: Intervention après le
User working before: Intervention avant le
by_job_working:
title: Filtrer les parcours par métier de l'intervenant, entre deux dates
'Filtered by job working on course: only %jobs%, between %start_date% and %end_date%': 'Filtré par métier des intervenants sur le parcours: seulement %jobs%, entre le %start_date% et le %end_date%'
Job working after: Intervention après le
Job working before: Intervention avant le
by_scope_working:
title: Filtrer les parcours par service de l'intervenant, entre deux dates
'Filtered by scope working on course: only %scopes%, between %start_date% and %end_date%': 'Filtré par service des intervenants sur le parcours: seulement %scopes%, entre le %start_date% et le %end_date%'
Scope working after: Intervention après le
Scope working before: Intervention avant le
by_step:
Filter by step: Filtrer les parcours par statut du parcours
Filter by step between dates: Filtrer les parcours par statut du parcours entre deux dates
@@ -1137,7 +1164,7 @@ export:
createdAt: Créé le
updatedAt: Dernière mise à jour le
acpOrigin: Origine du parcours
origin: Origine du parcourse
origin: Origine du parcours
acpClosingMotive: Motif de fermeture
acpJob: Métier du parcours
createdBy: Créé par

View File

@@ -299,13 +299,17 @@ final class SingleTaskController extends AbstractController
$this->denyAccessUnlessGranted(TaskVoter::SHOW, null);
$filterOrder = $this->buildFilterOrder();
$filteredUsers = $filterOrder->getUserPickerData('userPicker');
$flags = array_merge(
$filterOrder->getCheckboxData('status'),
array_map(static fn ($i) => 'state_' . $i, $filterOrder->getCheckboxData('states'))
);
$nb = $this->singleTaskAclAwareRepository->countByAllViewable(
$filterOrder->getQueryString(),
$flags
$flags,
$filteredUsers
);
$paginator = $this->paginatorFactory->create($nb);
@@ -313,6 +317,7 @@ final class SingleTaskController extends AbstractController
$tasks = $this->singleTaskAclAwareRepository->findByAllViewable(
$filterOrder->getQueryString(),
$flags,
$filteredUsers,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage(),
[
@@ -680,6 +685,7 @@ final class SingleTaskController extends AbstractController
->addSearchBox()
->addCheckbox('status', $statuses, $statuses, $statusTrans)
->addCheckbox('states', $states, ['new', 'in_progress'])
->addUserPicker('userPicker', 'Filter by user', ['multiple' => true, 'required' => false])
->build();
}

View File

@@ -51,7 +51,8 @@ final class SingleTaskAclAwareRepository implements SingleTaskAclAwareRepository
public function buildBaseQuery(
?string $pattern = null,
?array $flags = []
?array $flags = [],
?array $users = []
): QueryBuilder {
$qb = $this->em->createQueryBuilder();
$qb
@@ -62,6 +63,22 @@ final class SingleTaskAclAwareRepository implements SingleTaskAclAwareRepository
->setParameter('pattern', '%' . $pattern . '%');
}
if (count($users) > 0) {
$orXUser = $qb->expr()->orX();
foreach ($users as $key => $user) {
$orXUser->add(
$qb->expr()->eq('t.assignee', ':user_' . $key)
);
$qb->setParameter('user_' . $key, $user);
}
if ($orXUser->count() > 0) {
$qb->andWhere($orXUser);
}
}
if (count($flags) > 0) {
$orXDate = $qb->expr()->orX();
$orXState = $qb->expr()->orX();
@@ -183,9 +200,10 @@ final class SingleTaskAclAwareRepository implements SingleTaskAclAwareRepository
public function countByAllViewable(
?string $pattern = null,
?array $flags = []
?array $flags = [],
?array $users = []
): int {
$qb = $this->buildBaseQuery($pattern, $flags);
$qb = $this->buildBaseQuery($pattern, $flags, $users);
return $this
->addACLGlobal($qb)
@@ -231,11 +249,12 @@ final class SingleTaskAclAwareRepository implements SingleTaskAclAwareRepository
public function findByAllViewable(
?string $pattern = null,
?array $flags = [],
?array $users = [],
?int $start = 0,
?int $limit = 50,
?array $orderBy = []
): array {
$qb = $this->buildBaseQuery($pattern, $flags);
$qb = $this->buildBaseQuery($pattern, $flags, $users);
$qb = $this->addACLGlobal($qb);
return $this->getResult($qb, $start, $limit, $orderBy);

View File

@@ -18,7 +18,8 @@ interface SingleTaskAclAwareRepositoryInterface
{
public function countByAllViewable(
?string $pattern = null,
?array $flags = []
?array $flags = [],
?array $users = []
): int;
public function countByCourse(
@@ -38,6 +39,7 @@ interface SingleTaskAclAwareRepositoryInterface
public function findByAllViewable(
?string $pattern = null,
?array $flags = [],
?array $users = [],
?int $start = 0,
?int $limit = 50,
?array $orderBy = []

View File

@@ -27,8 +27,10 @@
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('page_task_list') }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('page_task_list') }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}

View File

@@ -65,6 +65,7 @@ Not assigned: Aucun utilisateur assigné
For person: Pour
By: Par
Any tasks: Aucune tâche
Filter by user: Filtrer par utilisateur(s)
# transitions - default task definition
"new": "nouvelle"