diff --git a/.changes/unreleased/Feature-20230613-151546.yaml b/.changes/unreleased/Feature-20230613-151546.yaml new file mode 100644 index 000000000..e66076aa5 --- /dev/null +++ b/.changes/unreleased/Feature-20230613-151546.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: Get an unified list of document in person and accompanying period context +time: 2023-06-13T15:15:46.146899906+02:00 +custom: + Issue: "103" diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityDocumentACLAwareRepository.php b/src/Bundle/ChillActivityBundle/Repository/ActivityDocumentACLAwareRepository.php new file mode 100644 index 000000000..ce70409ba --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityDocumentACLAwareRepository.php @@ -0,0 +1,198 @@ +buildBaseFetchQueryActivityDocumentLinkedToPersonFromPersonContext($person, $startDate, $endDate, $content); + + return $this->addFetchQueryByPersonACL($query, $person); + } + + public function buildBaseFetchQueryActivityDocumentLinkedToPersonFromPersonContext(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery + { + $storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class); + $activityMetadata = $this->em->getClassMetadata(Activity::class); + + $query = new FetchQuery( + PersonActivityGenericDocProvider::KEY, + sprintf('jsonb_build_object(\'id\', stored_obj.%s, \'activity_id\', activity.%s)', $storedObjectMetadata->getSingleIdentifierColumnName(), $activityMetadata->getSingleIdentifierColumnName()), + sprintf('stored_obj.%s', $storedObjectMetadata->getColumnName('createdAt')), + sprintf('%s AS stored_obj', $storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName()) + ); + + $query->addJoinClause( + 'JOIN public.activity_storedobject activity_doc ON activity_doc.storedobject_id = stored_obj.id' + ); + + $query->addJoinClause( + 'JOIN public.activity activity ON activity.id = activity_doc.activity_id' + ); + + $query->addWhereClause( + sprintf('activity.%s = ?', $activityMetadata->getSingleAssociationJoinColumnName('person')), + [$person->getId()], + [Types::INTEGER] + ); + + return $this->addWhereClauses($query, $startDate, $endDate, $content); + } + + public function buildFetchQueryActivityDocumentLinkedToAccompanyingPeriodFromPersonContext(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery + { + $storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class); + $activityMetadata = $this->em->getClassMetadata(Activity::class); + + $query = new FetchQuery( + AccompanyingPeriodActivityGenericDocProvider::KEY, + sprintf('jsonb_build_object(\'id\', stored_obj.%s, \'activity_id\', activity.%s)', $storedObjectMetadata->getSingleIdentifierColumnName(), $activityMetadata->getSingleIdentifierColumnName()), + sprintf('stored_obj.%s', $storedObjectMetadata->getColumnName('createdAt')), + sprintf('%s AS stored_obj', $storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName()) + ); + + $query->addJoinClause( + 'JOIN public.activity_storedobject activity_doc ON activity_doc.storedobject_id = stored_obj.id' + ); + + $query->addJoinClause( + 'JOIN public.activity activity ON activity.id = activity_doc.activity_id' + ); + + // add documents of activities from parcours context + $or = []; + $orParams = []; + $orTypes = []; + foreach ($person->getAccompanyingPeriodParticipations() as $participation) { + if (!$this->security->isGranted(ActivityVoter::SEE, $participation->getAccompanyingPeriod())) { + continue; + } + + $or[] = sprintf( + '(activity.%s = ? AND stored_obj.%s BETWEEN ?::date AND COALESCE(?::date, \'infinity\'::date))', + $activityMetadata->getSingleAssociationJoinColumnName('accompanyingPeriod'), + $storedObjectMetadata->getColumnName('createdAt') + ); + $orParams = [...$orParams, $participation->getAccompanyingPeriod()->getId(), + DateTimeImmutable::createFromInterface($participation->getStartDate()), + null === $participation->getEndDate() ? null : DateTimeImmutable::createFromInterface($participation->getEndDate())]; + $orTypes = [...$orTypes, Types::INTEGER, Types::DATE_IMMUTABLE, Types::DATE_IMMUTABLE]; + } + + if ([] === $or) { + $query->addWhereClause('TRUE = FALSE'); + + return $query; + } + + $query->addWhereClause(sprintf('(%s)', implode(' OR ', $or)), $orParams, $orTypes); + + return $this->addWhereClauses($query, $startDate, $endDate, $content); + } + + private function addWhereClauses(FetchQuery $query, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery + { + $storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class); + + if (null !== $startDate) { + $query->addWhereClause( + sprintf('stored_obj.%s >= ?', $storedObjectMetadata->getColumnName('createdAt')), + [$startDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $endDate) { + $query->addWhereClause( + sprintf('stored_obj.%s < ?', $storedObjectMetadata->getColumnName('createdAt')), + [$endDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $content and '' !== $content) { + $query->addWhereClause( + 'stored_obj.title ilike ?', + ['%' . $content . '%'], + [Types::STRING] + ); + } + + return $query; + } + + private function addFetchQueryByPersonACL(FetchQuery $fetchQuery, Person $person): FetchQuery + { + $activityMetadata = $this->em->getClassMetadata(Activity::class); + + $reachableScopes = []; + + foreach ($this->centerResolverManager->resolveCenters($person) as $center) { + $reachableScopes = [ + ...$reachableScopes, + ...$this->authorizationHelperForCurrentUser->getReachableScopes(ActivityVoter::SEE, $center) + ]; + } + + if ([] === $reachableScopes) { + $fetchQuery->addWhereClause('FALSE = TRUE'); + + return $fetchQuery; + } + + $fetchQuery->addWhereClause( + sprintf( + 'activity.%s IN (%s)', + $activityMetadata->getSingleAssociationJoinColumnName('scope'), + implode(', ', array_fill(0, count($reachableScopes), '?')) + ), + array_map(static fn (Scope $s) => $s->getId(), $reachableScopes), + array_fill(0, count($reachableScopes), Types::INTEGER) + ); + + return $fetchQuery; + } +} diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityDocumentACLAwareRepositoryInterface.php b/src/Bundle/ChillActivityBundle/Repository/ActivityDocumentACLAwareRepositoryInterface.php new file mode 100644 index 000000000..9f4a9c0f8 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityDocumentACLAwareRepositoryInterface.php @@ -0,0 +1,37 @@ + +
+
+ {% if document.isPending %} +
{{ 'docgen.Doc generation is pending'|trans }}
+ {% elseif document.isFailure %} +
{{ 'docgen.Doc generation failed'|trans }}
+ {% endif %} + +
+ {% if activity.accompanyingPeriod is not null and context == 'person' %} + + {{ activity.accompanyingPeriod.id }} +   + {% endif %} +
+ + + {{ activity.type.name | localize_translatable_string }} + {% if activity.emergency %} + {{ 'Emergency'|trans|upper }} + {% endif %} + +
+
+
+ {{ document.title|chill_print_or_message("No title") }} +
+ {% if document.hasTemplate %} +
+

{{ document.template.name|localize_translatable_string }}

+
+ {% endif %} +
+ +
+
+
+ {{ document.createdAt|format_date('short') }} +
+
+
+
+ + +
+
+ {{ mmm.createdBy(document) }} +
+ + +
+ diff --git a/src/Bundle/ChillActivityBundle/Service/GenericDoc/Providers/AccompanyingPeriodActivityGenericDocProvider.php b/src/Bundle/ChillActivityBundle/Service/GenericDoc/Providers/AccompanyingPeriodActivityGenericDocProvider.php new file mode 100644 index 000000000..334b5d2df --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Service/GenericDoc/Providers/AccompanyingPeriodActivityGenericDocProvider.php @@ -0,0 +1,114 @@ +em->getClassMetadata(StoredObject::class); + $activityMetadata = $this->em->getClassMetadata(Activity::class); + + $query = new FetchQuery( + self::KEY, + sprintf("jsonb_build_object('id', doc_obj.%s, 'activity_id', activity.%s)", $storedObjectMetadata->getSingleIdentifierColumnName(), $activityMetadata->getSingleIdentifierColumnName()), + 'doc_obj.'.$storedObjectMetadata->getColumnName('createdAt'), + $storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName().' AS doc_obj' + ); + + $query->addJoinClause( + 'JOIN public.activity_storedobject activity_doc ON activity_doc.storedobject_id = doc_obj.id' + ); + + $query->addJoinClause( + 'JOIN public.activity activity ON activity.id = activity_doc.activity_id' + ); + + $query->addWhereClause( + 'activity.accompanyingperiod_id = ?', + [$accompanyingPeriod->getId()], + [Types::INTEGER] + ); + + if (null !== $startDate) { + $query->addWhereClause( + sprintf('doc_obj.%s >= ?', $storedObjectMetadata->getColumnName('createdAt')), + [$startDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $endDate) { + $query->addWhereClause( + sprintf('doc_obj.%s < ?', $storedObjectMetadata->getColumnName('createdAt')), + [$endDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $content) { + $query->addWhereClause( + 'doc_obj.title ilike ?', + ['%' . $content . '%'], + [Types::STRING] + ); + } + + return $query; + } + + /** + * @param AccompanyingPeriod $accompanyingPeriod + * @return bool + */ + public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool + { + return $this->security->isGranted(ActivityVoter::SEE, $accompanyingPeriod); + } + + public function isAllowedForPerson(Person $person): bool + { + return $this->security->isGranted(AccompanyingPeriodVoter::SEE, $person); + } + + public function buildFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface + { + return $this->activityDocumentACLAwareRepository + ->buildFetchQueryActivityDocumentLinkedToAccompanyingPeriodFromPersonContext($person, $startDate, $endDate, $content); + } +} diff --git a/src/Bundle/ChillActivityBundle/Service/GenericDoc/Providers/PersonActivityGenericDocProvider.php b/src/Bundle/ChillActivityBundle/Service/GenericDoc/Providers/PersonActivityGenericDocProvider.php new file mode 100644 index 000000000..cf96449ab --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Service/GenericDoc/Providers/PersonActivityGenericDocProvider.php @@ -0,0 +1,53 @@ +personActivityDocumentACLAwareRepository->buildFetchQueryActivityDocumentLinkedToPersonFromPersonContext( + $person, + $startDate, + $endDate, + $content + ); + } + + /** + * @param Person $person + * @return bool + */ + public function isAllowedForPerson(Person $person): bool + { + return $this->security->isGranted(ActivityVoter::SEE, $person); + } +} diff --git a/src/Bundle/ChillActivityBundle/Service/GenericDoc/Renderers/AccompanyingPeriodActivityGenericDocRenderer.php b/src/Bundle/ChillActivityBundle/Service/GenericDoc/Renderers/AccompanyingPeriodActivityGenericDocRenderer.php new file mode 100644 index 000000000..c465dbca1 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Service/GenericDoc/Renderers/AccompanyingPeriodActivityGenericDocRenderer.php @@ -0,0 +1,52 @@ +objectRepository = $storedObjectRepository; + $this->activityRepository = $activityRepository; + } + + public function supports(GenericDocDTO $genericDocDTO, $options = []): bool + { + return $genericDocDTO->key === AccompanyingPeriodActivityGenericDocProvider::KEY || $genericDocDTO->key === PersonActivityGenericDocProvider::KEY; + } + + public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string + { + return '@ChillActivity/GenericDoc/activity_document.html.twig'; + } + + public function getTemplateData(GenericDocDTO $genericDocDTO, $options = []): array + { + return [ + 'activity' => $this->activityRepository->find($genericDocDTO->identifiers['activity_id']), + 'document' => $this->objectRepository->find($genericDocDTO->identifiers['id']), + 'context' => $genericDocDTO->getContext(), + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Tests/Repository/ActivityDocumentACLAwareRepositoryTest.php b/src/Bundle/ChillActivityBundle/Tests/Repository/ActivityDocumentACLAwareRepositoryTest.php new file mode 100644 index 000000000..ce4f318e3 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Repository/ActivityDocumentACLAwareRepositoryTest.php @@ -0,0 +1,126 @@ +entityManager = self::$container->get(EntityManagerInterface::class); + $this->centerResolverManager = self::$container->get(CenterResolverManagerInterface::class); + $this->authorizationHelperForCurrentUser = self::$container->get(AuthorizationHelperForCurrentUserInterface::class); + $this->security = self::$container->get(Security::class); + } + + /** + * @dataProvider provideDataForPerson + */ + public function testBuildFetchQueryActivityDocumentLinkedToPersonFromPersonContext(Person $person, array $reachableScopes, bool $_unused, ?\DateTimeImmutable $startDate, ?\DateTimeImmutable $endDate, ?string $content): void + { + $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); + $authorizationHelper->getReachableScopes(ActivityVoter::SEE, Argument::any()) + ->willReturn($reachableScopes); + + $repository = new ActivityDocumentACLAwareRepository( + $this->entityManager, + $this->centerResolverManager, + $authorizationHelper->reveal(), + $this->security + ); + + $query = $repository->buildFetchQueryActivityDocumentLinkedToPersonFromPersonContext($person, $startDate, $endDate, $content); + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection()->fetchOne("SELECT COUNT(*) FROM ({$sql}) sq", $params, $types); + + self::assertIsInt($nb); + } + + /** + * @dataProvider provideDataForPerson + */ + public function testBuildFetchQueryActivityDocumentLinkedToAccompanyingPeriodFromPersonContext(Person $person, array $_unused, bool $canSeePeriod, ?\DateTimeImmutable $startDate, ?\DateTimeImmutable $endDate, ?string $content): void + { + $security = $this->prophesize(Security::class); + $security->isGranted(ActivityVoter::SEE, Argument::type(AccompanyingPeriod::class)) + ->willReturn($canSeePeriod); + + $repository = new ActivityDocumentACLAwareRepository( + $this->entityManager, + $this->centerResolverManager, + $this->authorizationHelperForCurrentUser, + $security->reveal() + ); + + $query = $repository->buildFetchQueryActivityDocumentLinkedToAccompanyingPeriodFromPersonContext($person, $startDate, $endDate, $content); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection()->fetchOne("SELECT COUNT(*) FROM ({$sql}) sq", $params, $types); + + self::assertIsInt($nb); + } + + public function provideDataForPerson(): iterable + { + $this->setUp(); + + if (null === $person = $this->entityManager->createQuery("SELECT p FROM " . Person::class . " p WHERE SIZE(p.accompanyingPeriodParticipations) > 0 ") + ->setMaxResults(1) + ->getSingleResult()) { + throw new \RuntimeException("no person in dtabase"); + } + + if ([] === $scopes = $this->entityManager->createQuery("SELECT s FROM " . Scope::class . " s ")->setMaxResults(5)->getResult()) { + throw new \RuntimeException("no scopes in database"); + } + + yield [$person, [], true, null, null, null]; + yield [$person, $scopes, true, null, null, null]; + yield [$person, $scopes, true, new \DateTimeImmutable("1 month ago"), null, null]; + yield [$person, $scopes, true, new \DateTimeImmutable("1 month ago"), new \DateTimeImmutable("1 week ago"), null]; + yield [$person, $scopes, true, new \DateTimeImmutable("1 month ago"), new \DateTimeImmutable("1 week ago"), "content"]; + yield [$person, $scopes, true, null, new \DateTimeImmutable("1 week ago"), "content"]; + yield [$person, [], true, new \DateTimeImmutable("1 month ago"), new \DateTimeImmutable("1 week ago"), "content"]; + } + +} diff --git a/src/Bundle/ChillActivityBundle/config/services.yaml b/src/Bundle/ChillActivityBundle/config/services.yaml index d55f86d4f..18be76ec9 100644 --- a/src/Bundle/ChillActivityBundle/config/services.yaml +++ b/src/Bundle/ChillActivityBundle/config/services.yaml @@ -38,3 +38,6 @@ services: Chill\ActivityBundle\Service\EntityInfo\: resource: '../Service/EntityInfo/' + + Chill\ActivityBundle\Service\GenericDoc\: + resource: '../Service/GenericDoc/' diff --git a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml index 042fccc69..abef160d3 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml @@ -372,3 +372,8 @@ export: is sent: envoyé is received: reçu Group activity by sentreceived: Grouper les échanges par envoyé / reçu + +generic_doc: + filter: + keys: + accompanying_period_activity_document: Document des échanges des parcours diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/chill/chill.js b/src/Bundle/ChillCalendarBundle/Resources/public/chill/chill.js new file mode 100644 index 000000000..56a8ce563 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/chill/chill.js @@ -0,0 +1 @@ +import './scss/badge.scss'; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/badge.scss b/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/badge.scss new file mode 100644 index 000000000..ffcda8f0f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/badge.scss @@ -0,0 +1,25 @@ +@import '~ChillPersonAssets/chill/scss/mixins.scss'; +@import '~ChillMainAssets/module/bootstrap/shared'; + +.badge-calendar { + display: inline-block; + background-color: #f3f3f3; + + .title_label { + @include chill_badge($chill-l-gray); + } + + .title_action { + padding: var(--bs-badge-padding-y) var(--bs-badge-padding-x); + margin-right: 1rem; + + font-size: var(--bs-badge-font-size); + font-weight: var(--bs-badge-font-weight); + line-height: 1; + color: var(--bs-badge-color); + text-align: center; + white-space: nowrap; + vertical-align: baseline; + } +} + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/calendar.scss b/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/calendar.scss index a2c0c4b89..ce54b0fa8 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/calendar.scss +++ b/src/Bundle/ChillCalendarBundle/Resources/public/chill/scss/calendar.scss @@ -17,4 +17,4 @@ span.calendarRangeItems { text-decoration: none; padding: 3px; } -} \ No newline at end of file +} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/GenericDoc/calendar_document.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/GenericDoc/calendar_document.html.twig new file mode 100644 index 000000000..facf5be50 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/GenericDoc/calendar_document.html.twig @@ -0,0 +1,75 @@ +{% import "@ChillDocStore/Macro/macro.html.twig" as m %} +{% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %} +{% import '@ChillPerson/Macro/updatedBy.html.twig' as mmm %} + +{% set c = document.calendar %} + +
+
+
+ {% if document.storedObject.isPending %} +
{{ 'docgen.Doc generation is pending'|trans }}
+ {% elseif document.storedObject.isFailure %} +
{{ 'docgen.Doc generation failed'|trans }}
+ {% endif %} + +
+ {% if c.accompanyingPeriod is not null and context == 'person' %} + + {{ c.accompanyingPeriod.id }} +   + {% endif %} + + + + + {{ 'Calendar'|trans }} + {% if c.endDate.diff(c.startDate).days >= 1 %} + {{ c.startDate|format_datetime('short', 'short') }} + - {{ c.endDate|format_datetime('short', 'short') }} + {% else %} + {{ c.startDate|format_datetime('short', 'short') }} + - {{ c.endDate|format_datetime('none', 'short') }} + {% endif %} + + +
+ +
+ {{ document.storedObject.title|chill_print_or_message("No title") }} +
+ {% if document.storedObject.hasTemplate %} +
+

{{ document.storedObject.template.name|localize_translatable_string }}

+
+ {% endif %} +
+ +
+
+
+ {{ document.storedObject.createdAt|format_date('short') }} +
+
+
+
+ +
+
+ {{ mmm.createdBy(document) }} +
+ + +
+
diff --git a/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Providers/AccompanyingPeriodCalendarGenericDocProvider.php b/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Providers/AccompanyingPeriodCalendarGenericDocProvider.php new file mode 100644 index 000000000..c33ccd853 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Providers/AccompanyingPeriodCalendarGenericDocProvider.php @@ -0,0 +1,192 @@ +em->getClassMetadata(CalendarDoc::class); + $storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class); + $calendarMetadata = $this->em->getClassMetadata(Calendar::class); + + $query = new FetchQuery( + self::KEY, + sprintf("jsonb_build_object('id', cd.%s)", $classMetadata->getColumnName('id')), + 'cd.'.$storedObjectMetadata->getColumnName('createdAt'), + $classMetadata->getSchemaName().'.'.$classMetadata->getTableName().' AS cd' + ); + $query->addJoinClause( + sprintf( + 'JOIN %s doc_store ON doc_store.%s = cd.%s', + $storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName(), + $storedObjectMetadata->getColumnName('id'), + $classMetadata->getSingleAssociationJoinColumnName('storedObject') + ) + ); + + $query->addJoinClause( + sprintf( + 'JOIN %s calendar ON calendar.%s = cd.%s', + $calendarMetadata->getSchemaName().'.'.$calendarMetadata->getTableName(), + $calendarMetadata->getColumnName('id'), + $classMetadata->getSingleAssociationJoinColumnName('calendar') + ) + ); + + $query->addWhereClause( + sprintf( + 'calendar.%s = ?', + $calendarMetadata->getAssociationMapping('accompanyingPeriod')['joinColumns'][0]['name'] + ), + [$accompanyingPeriod->getId()], + [Types::INTEGER] + ); + + return $query; + } + + public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool + { + return $this->security->isGranted(CalendarVoter::SEE, $accompanyingPeriod); + } + + public function buildFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface + { + $classMetadata = $this->em->getClassMetadata(CalendarDoc::class); + $storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class); + $calendarMetadata = $this->em->getClassMetadata(Calendar::class); + + $query = new FetchQuery( + self::KEY, + sprintf("jsonb_build_object('id', cd.%s)", $classMetadata->getColumnName('id')), + 'cd.'.$storedObjectMetadata->getColumnName('createdAt'), + $classMetadata->getSchemaName().'.'.$classMetadata->getTableName().' AS cd' + ); + $query->addJoinClause( + sprintf( + 'JOIN %s doc_store ON doc_store.%s = cd.%s', + $storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName(), + $storedObjectMetadata->getColumnName('id'), + $classMetadata->getSingleAssociationJoinColumnName('storedObject') + ) + ); + + $query->addJoinClause( + sprintf( + 'JOIN %s calendar ON calendar.%s = cd.%s', + $calendarMetadata->getSchemaName().'.'.$calendarMetadata->getTableName(), + $calendarMetadata->getColumnName('id'), + $classMetadata->getSingleAssociationJoinColumnName('calendar') + ) + ); + + // get the documents associated with accompanying periods in which person participates + $or = []; + $orParams = []; + $orTypes = []; + foreach ($person->getAccompanyingPeriodParticipations() as $participation) { + if (!$this->security->isGranted(CalendarVoter::SEE, $participation->getAccompanyingPeriod())) { + continue; + } + + $or[] = sprintf( + '(calendar.%s = ? AND cd.%s BETWEEN ?::date AND COALESCE(?::date, \'infinity\'::date))', + $calendarMetadata->getSingleAssociationJoinColumnName('accompanyingPeriod'), + $storedObjectMetadata->getColumnName('createdAt') + ); + $orParams = [...$orParams, $participation->getAccompanyingPeriod()->getId(), + DateTimeImmutable::createFromInterface($participation->getStartDate()), + null === $participation->getEndDate() ? null : DateTimeImmutable::createFromInterface($participation->getEndDate())]; + $orTypes = [...$orTypes, Types::INTEGER, Types::DATE_IMMUTABLE, Types::DATE_IMMUTABLE]; + } + + if ([] === $or) { + $query->addWhereClause('TRUE = FALSE'); + + return $query; + } + return $this->addWhereClausesToQuery($query, $startDate, $endDate, $content); + } + + public function isAllowedForPerson(Person $person): bool + { + // check that the person is allowed to see an accompanying period. If yes, the + // ACL on each accompanying period will be checked when the query is build + return $this->security->isGranted(AccompanyingPeriodVoter::SEE, $person); + } + + private function addWhereClausesToQuery(FetchQuery $query, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?string $content): FetchQuery + { + $storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class); + + if (null !== $startDate) { + $query->addWhereClause( + sprintf('doc_store.%s >= ?', $storedObjectMetadata->getColumnName('createdAt')), + [$startDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $endDate) { + $query->addWhereClause( + sprintf('doc_store.%s < ?', $storedObjectMetadata->getColumnName('createdAt')), + [$endDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $content) { + $query->addWhereClause( + sprintf('doc_store.%s ilike ?', $storedObjectMetadata->getColumnName('title')), + ['%' . $content . '%'], + [Types::STRING] + ); + } + + return $query; + } + + +} diff --git a/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Providers/PersonCalendarGenericDocProvider.php b/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Providers/PersonCalendarGenericDocProvider.php new file mode 100644 index 000000000..f5d4b3cbb --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Providers/PersonCalendarGenericDocProvider.php @@ -0,0 +1,126 @@ +em->getClassMetadata(StoredObject::class); + + if (null !== $startDate) { + $query->addWhereClause( + sprintf('doc_store.%s >= ?', $storedObjectMetadata->getColumnName('createdAt')), + [$startDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $endDate) { + $query->addWhereClause( + sprintf('doc_store.%s < ?', $storedObjectMetadata->getColumnName('createdAt')), + [$endDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $content) { + $query->addWhereClause( + sprintf('doc_store.%s ilike ?', $storedObjectMetadata->getColumnName('title')), + ['%' . $content . '%'], + [Types::STRING] + ); + } + + return $query; + } + + /** + * @throws MappingException + */ + public function buildFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface + { + $classMetadata = $this->em->getClassMetadata(CalendarDoc::class); + $storedObjectMetadata = $this->em->getClassMetadata(StoredObject::class); + $calendarMetadata = $this->em->getClassMetadata(Calendar::class); + + $query = new FetchQuery( + self::KEY, + sprintf("jsonb_build_object('id', cd.%s)", $classMetadata->getColumnName('id')), + 'cd.'.$storedObjectMetadata->getColumnName('createdAt'), + $classMetadata->getSchemaName().'.'.$classMetadata->getTableName().' AS cd' + ); + $query->addJoinClause( + sprintf( + 'JOIN %s doc_store ON doc_store.%s = cd.%s', + $storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName(), + $storedObjectMetadata->getColumnName('id'), + $classMetadata->getSingleAssociationJoinColumnName('storedObject') + ) + ); + + $query->addJoinClause( + sprintf( + 'JOIN %s calendar ON calendar.%s = cd.%s', + $calendarMetadata->getSchemaName().'.'.$calendarMetadata->getTableName(), + $calendarMetadata->getColumnName('id'), + $classMetadata->getSingleAssociationJoinColumnName('calendar') + ) + ); + + $query->addWhereClause( + sprintf('calendar.%s = ?', $calendarMetadata->getSingleAssociationJoinColumnName('person')), + [$person->getId()], + [Types::INTEGER] + ); + + return $this->addWhereClausesToQuery($query, $startDate, $endDate, $content); + } + + /** + * @param Person $person + * @return bool + */ + public function isAllowedForPerson(Person $person): bool + { + return $this->security->isGranted(CalendarVoter::SEE, $person); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Renderers/AccompanyingPeriodCalendarGenericDocRenderer.php b/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Renderers/AccompanyingPeriodCalendarGenericDocRenderer.php new file mode 100644 index 000000000..b15c091c6 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/GenericDoc/Renderers/AccompanyingPeriodCalendarGenericDocRenderer.php @@ -0,0 +1,46 @@ +repository = $calendarDocRepository; + } + + public function supports(GenericDocDTO $genericDocDTO, $options = []): bool + { + return $genericDocDTO->key === AccompanyingPeriodCalendarGenericDocProvider::KEY || $genericDocDTO->key === PersonCalendarGenericDocProvider::KEY; + } + + public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string + { + return '@ChillCalendar/GenericDoc/calendar_document.html.twig'; + } + + public function getTemplateData(GenericDocDTO $genericDocDTO, $options = []): array + { + return [ + 'document' => $this->repository->find($genericDocDTO->identifiers['id']), + 'context' => $genericDocDTO->getContext(), + ]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/chill.webpack.config.js b/src/Bundle/ChillCalendarBundle/chill.webpack.config.js index e82210087..9d45a3142 100644 --- a/src/Bundle/ChillCalendarBundle/chill.webpack.config.js +++ b/src/Bundle/ChillCalendarBundle/chill.webpack.config.js @@ -1,6 +1,8 @@ // this file loads all assets from the Chill calendar bundle module.exports = function(encore, entries) { + entries.push(__dirname + '/Resources/public/chill/chill.js'); + encore.addAliases({ ChillCalendarAssets: __dirname + '/Resources/public' }); diff --git a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml index eb02be280..c56d7835f 100644 --- a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml @@ -43,6 +43,7 @@ crud: title_edit: Modifier le motif d'annulation chill_calendar: + Document: Document d'un rendez-vous form: The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement. Create for referrer: Créer pour le référent @@ -65,6 +66,7 @@ chill_calendar: Document outdated: La date et l'heure du rendez-vous ont été modifiés après la création du document + remote_ms_graph: freebusy_statuses: busy: Occupé @@ -145,3 +147,9 @@ CHILL_CALENDAR_CALENDAR_EDIT: Modifier les rendez-vous CHILL_CALENDAR_CALENDAR_DELETE: Supprimer les rendez-vous CHILL_CALENDAR_CALENDAR_SEE: Voir les rendez-vous + +generic_doc: + filter: + keys: + accompanying_period_calendar_document: Document des rendez-vous des parcours + person_calendar_document: Document des rendez-vous de l'usager diff --git a/src/Bundle/ChillDocStoreBundle/ChillDocStoreBundle.php b/src/Bundle/ChillDocStoreBundle/ChillDocStoreBundle.php index 81c71f45f..8dcbe72c3 100644 --- a/src/Bundle/ChillDocStoreBundle/ChillDocStoreBundle.php +++ b/src/Bundle/ChillDocStoreBundle/ChillDocStoreBundle.php @@ -11,8 +11,21 @@ declare(strict_types=1); namespace Chill\DocStoreBundle; +use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface; +use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface; +use Chill\DocStoreBundle\GenericDoc\Twig\GenericDocRendererInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class ChillDocStoreBundle extends Bundle { + public function build(ContainerBuilder $container) + { + $container->registerForAutoconfiguration(GenericDocForAccompanyingPeriodProviderInterface::class) + ->addTag('chill_doc_store.generic_doc_accompanying_period_provider'); + $container->registerForAutoconfiguration(GenericDocForPersonProviderInterface::class) + ->addTag('chill_doc_store.generic_doc_person_provider'); + $container->registerForAutoconfiguration(GenericDocRendererInterface::class) + ->addTag('chill_doc_store.generic_doc_renderer'); + } } diff --git a/src/Bundle/ChillDocStoreBundle/Controller/DocumentAccompanyingCourseController.php b/src/Bundle/ChillDocStoreBundle/Controller/DocumentAccompanyingCourseController.php index 1bc3db221..384eeb510 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/DocumentAccompanyingCourseController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/DocumentAccompanyingCourseController.php @@ -39,10 +39,6 @@ class DocumentAccompanyingCourseController extends AbstractController protected TranslatorInterface $translator; - private AccompanyingCourseDocumentRepository $courseRepository; - - private PaginatorFactory $paginatorFactory; - /** * DocumentAccompanyingCourseController constructor. */ @@ -50,14 +46,10 @@ class DocumentAccompanyingCourseController extends AbstractController TranslatorInterface $translator, EventDispatcherInterface $eventDispatcher, AuthorizationHelper $authorizationHelper, - PaginatorFactory $paginatorFactory, - AccompanyingCourseDocumentRepository $courseRepository ) { $this->translator = $translator; $this->eventDispatcher = $eventDispatcher; $this->authorizationHelper = $authorizationHelper; - $this->paginatorFactory = $paginatorFactory; - $this->courseRepository = $courseRepository; } /** @@ -82,7 +74,7 @@ class DocumentAccompanyingCourseController extends AbstractController return $this->redirect($request->query->get('returnPath')); } - return $this->redirectToRoute('accompanying_course_document_index', ['course' => $course->getId()]); + return $this->redirectToRoute('chill_docstore_generic-doc_by-period_index', ['id' => $course->getId()]); } return $this->render( @@ -136,40 +128,6 @@ class DocumentAccompanyingCourseController extends AbstractController ); } - /** - * @Route("/", name="accompanying_course_document_index", methods="GET") - */ - public function index(AccompanyingPeriod $course): Response - { - $em = $this->getDoctrine()->getManager(); - - if (null === $course) { - throw $this->createNotFoundException('Accompanying period not found'); - } - - $this->denyAccessUnlessGranted(AccompanyingCourseDocumentVoter::SEE, $course); - - $total = $this->courseRepository->countByCourse($course); - $pagination = $this->paginatorFactory->create($total); - - $documents = $this->courseRepository - ->findBy( - ['course' => $course], - ['date' => 'DESC', 'id' => 'DESC'], - $pagination->getItemsPerPage(), - $pagination->getCurrentPageFirstItemNumber() - ); - - return $this->render( - 'ChillDocStoreBundle:AccompanyingCourseDocument:index.html.twig', - [ - 'documents' => $documents, - 'accompanyingCourse' => $course, - 'pagination' => $pagination, - ] - ); - } - /** * @Route("/new", name="accompanying_course_document_new", methods="GET|POST") */ @@ -202,7 +160,7 @@ class DocumentAccompanyingCourseController extends AbstractController $this->addFlash('success', $this->translator->trans('The document is successfully registered')); - return $this->redirectToRoute('accompanying_course_document_index', ['course' => $course->getId()]); + return $this->redirectToRoute('chill_docstore_generic-doc_by-period_index', ['id' => $course->getId()]); } if ($form->isSubmitted() && !$form->isValid()) { diff --git a/src/Bundle/ChillDocStoreBundle/Controller/DocumentPersonController.php b/src/Bundle/ChillDocStoreBundle/Controller/DocumentPersonController.php index 6fcb6a8e5..20e8e9b03 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/DocumentPersonController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/DocumentPersonController.php @@ -45,10 +45,6 @@ class DocumentPersonController extends AbstractController protected TranslatorInterface $translator; - private PaginatorFactory $paginatorFactory; - - private PersonDocumentACLAwareRepositoryInterface $personDocumentACLAwareRepository; - /** * DocumentPersonController constructor. */ @@ -56,14 +52,10 @@ class DocumentPersonController extends AbstractController TranslatorInterface $translator, EventDispatcherInterface $eventDispatcher, AuthorizationHelper $authorizationHelper, - PaginatorFactory $paginatorFactory, - PersonDocumentACLAwareRepositoryInterface $personDocumentACLAwareRepository ) { $this->translator = $translator; $this->eventDispatcher = $eventDispatcher; $this->authorizationHelper = $authorizationHelper; - $this->paginatorFactory = $paginatorFactory; - $this->personDocumentACLAwareRepository = $personDocumentACLAwareRepository; } /** @@ -88,7 +80,7 @@ class DocumentPersonController extends AbstractController return $this->redirect($request->query->get('returnPath')); } - return $this->redirectToRoute('person_document_index', ['person' => $person->getId()]); + return $this->redirectToRoute('chill_docstore_generic-doc_by-person_index', ['id' => $person->getId()]); } return $this->render( @@ -160,45 +152,6 @@ class DocumentPersonController extends AbstractController ); } - /** - * @Route("/", name="person_document_index", methods="GET") - */ - public function index(Person $person): Response - { - $em = $this->getDoctrine()->getManager(); - - if (null === $person) { - throw $this->createNotFoundException('Person not found'); - } - - $this->denyAccessUnlessGranted(PersonVoter::SEE, $person); - - $total = $this->personDocumentACLAwareRepository->countByPerson($person); - $pagination = $this->paginatorFactory->create($total); - - $documents = $this->personDocumentACLAwareRepository->findByPerson( - $person, - ['date' => 'DESC', 'id' => 'DESC'], - $pagination->getItemsPerPage(), - $pagination->getCurrentPageFirstItemNumber() - ); - - $event = new PrivacyEvent($person, [ - 'element_class' => PersonDocument::class, - 'action' => 'index', - ]); - $this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event); - - return $this->render( - 'ChillDocStoreBundle:PersonDocument:index.html.twig', - [ - 'documents' => $documents, - 'person' => $person, - 'pagination' => $pagination, - ] - ); - } - /** * @Route("/new", name="person_document_new", methods="GET|POST") */ @@ -233,7 +186,7 @@ class DocumentPersonController extends AbstractController $this->addFlash('success', $this->translator->trans('The document is successfully registered')); - return $this->redirectToRoute('person_document_index', ['person' => $person->getId()]); + return $this->redirectToRoute('chill_docstore_generic-doc_by-person_index', ['id' => $person->getId()]); } if ($form->isSubmitted() && !$form->isValid()) { diff --git a/src/Bundle/ChillDocStoreBundle/Controller/GenericDocForAccompanyingPeriodController.php b/src/Bundle/ChillDocStoreBundle/Controller/GenericDocForAccompanyingPeriodController.php new file mode 100644 index 000000000..70c41db50 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/GenericDocForAccompanyingPeriodController.php @@ -0,0 +1,98 @@ +security->isGranted(AccompanyingCourseDocumentVoter::SEE, $accompanyingPeriod)) { + throw new AccessDeniedHttpException("not allowed to see the documents for accompanying period"); + } + + $filterBuilder = $this->filterOrderHelperFactory + ->create(self::class) + ->addSearchBox() + ->addDateRange('dateRange', 'generic_doc.filter.date-range'); + + if ([] !== $places = $this->manager->placesForAccompanyingPeriod($accompanyingPeriod)) { + $filterBuilder->addCheckbox('places', $places, [], array_map( + static fn (string $k) => 'generic_doc.filter.keys.' . $k, + $places + )); + } + + $filter = $filterBuilder + ->build(); + + ['to' => $endDate, 'from' => $startDate ] = $filter->getDateRangeData('dateRange'); + $content = $filter->getQueryString(); + + $nb = $this->manager->countDocForAccompanyingPeriod( + $accompanyingPeriod, + $startDate, + $endDate, + $content, + $filter->hasCheckBox('places') ? array_values($filter->getCheckboxData('places')) : [] + ); + $paginator = $this->paginator->create($nb); + + $documents = $this->manager->findDocForAccompanyingPeriod( + $accompanyingPeriod, + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage(), + $startDate, + $endDate, + $content, + $filter->hasCheckBox('places') ? array_values($filter->getCheckboxData('places')) : [] + ); + + return new Response($this->twig->render( + '@ChillDocStore/GenericDoc/accompanying_period_list.html.twig', + [ + 'accompanyingCourse' => $accompanyingPeriod, + 'pagination' => $paginator, + 'documents' => iterator_to_array($documents), + 'filter' => $filter, + ] + )); + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/GenericDocForPerson.php b/src/Bundle/ChillDocStoreBundle/Controller/GenericDocForPerson.php new file mode 100644 index 000000000..3484e0904 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/GenericDocForPerson.php @@ -0,0 +1,95 @@ +security->isGranted(PersonDocumentVoter::SEE, $person)) { + throw new AccessDeniedHttpException("not allowed to see the documents for person"); + } + + $filterBuilder = $this->filterOrderHelperFactory + ->create(self::class) + ->addSearchBox() + ->addDateRange('dateRange', 'generic_doc.filter.date-range'); + + if ([] !== $places = $this->manager->placesForPerson($person)) { + $filterBuilder->addCheckbox('places', $places, [], array_map( + static fn (string $k) => 'generic_doc.filter.keys.' . $k, + $places + )); + } + + $filter = $filterBuilder + ->build(); + + ['to' => $endDate, 'from' => $startDate ] = $filter->getDateRangeData('dateRange'); + $content = $filter->getQueryString(); + + $nb = $this->manager->countDocForPerson( + $person, + $startDate, + $endDate, + $content, + $filter->hasCheckBox('places') ? array_values($filter->getCheckboxData('places')) : [] + ); + $paginator = $this->paginator->create($nb); + + $documents = $this->manager->findDocForPerson( + $person, + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage(), + $startDate, + $endDate, + $content, + $filter->hasCheckBox('places') ? array_values($filter->getCheckboxData('places')) : [] + ); + + return new Response($this->twig->render( + '@ChillDocStore/GenericDoc/person_list.html.twig', + [ + 'person' => $person, + 'pagination' => $paginator, + 'documents' => iterator_to_array($documents), + 'filter' => $filter, + ] + )); + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQuery.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQuery.php new file mode 100644 index 000000000..30e07a841 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQuery.php @@ -0,0 +1,233 @@ + + */ + private array $joins = []; + + /** + * @var list> + */ + private array $joinParams = []; + + /** + * @var array> + */ + private array $joinTypes = []; + + /** + * @var array + */ + private array $wheres = []; + + /** + * @var array> + */ + private array $whereParams = []; + + /** + * @var array> + */ + private array $whereTypes = []; + + public function __construct( + private readonly string $selectKeyString, + private readonly string $selectIdentifierJsonB, + private readonly string $selectDate, + private string $from = '', + private array $selectIdentifierParams = [], + private array $selectIdentifierTypes = [], + private array $selectDateParams = [], + private array $selectDateTypes = [], + ) { + } + + public function addJoinClause(string $sql, array $params = [], array $types = []): int + { + $this->joins[] = $sql; + $this->joinParams[] = $params; + $this->joinTypes[] = $types; + + return count($this->joins) - 1; + } + + public function addWhereClause(string $sql, array $params = [], array $types = []): int + { + $this->wheres[] = $sql; + $this->whereParams[] = $params; + $this->whereTypes[] = $types; + + return count($this->wheres) - 1; + } + + public function removeWhereClause(int $index): void + { + if (!array_key_exists($index, $this->wheres)) { + throw new \UnexpectedValueException("this index does not exists"); + } + + unset($this->wheres[$index], $this->whereParams[$index], $this->whereTypes[$index]); + + } + + public function removeJoinClause(int $index): void + { + if (!array_key_exists($index, $this->joins)) { + throw new \UnexpectedValueException("this index does not exists"); + } + + unset($this->joins[$index], $this->joinParams[$index], $this->joinTypes[$index]); + + } + + public function getSelectKeyString(): string + { + return $this->selectKeyString; + } + + public function getSelectIdentifierJsonB(): string + { + return $this->selectIdentifierJsonB; + } + + /** + * @inheritDoc + */ + public function getSelectIdentifierParams(): array + { + return $this->selectIdentifierParams; + } + + public function getSelectIdentifiersTypes(): array + { + return $this->selectIdentifierTypes; + } + + public function getSelectDate(): string + { + return $this->selectDate; + } + + public function getSelectDateTypes(): array + { + return $this->selectDateTypes; + } + + /** + * @inheritDoc + */ + public function getSelectDateParams(): array + { + return $this->selectDateParams; + } + + public function getFromQuery(): string + { + return $this->from . " " . implode(' ', $this->joins); + } + + /** + * @inheritDoc + */ + public function getFromQueryParams(): array + { + $result = []; + + foreach ($this->joinParams as $params) { + $result = [...$result, ...$params]; + } + + return $result; + } + + public function getFromQueryTypes(): array + { + $result = []; + + foreach ($this->joinTypes as $types) { + $result = [...$result, ...$types]; + } + + return $result; + } + + public function getWhereQuery(): string + { + return implode(' AND ', $this->wheres); + } + + /** + * @inheritDoc + */ + public function getWhereQueryParams(): array + { + $result = []; + + foreach ($this->whereParams as $params) { + $result = [...$result, ...$params]; + } + + return $result; + } + + public function getWhereQueryTypes(): array + { + $result = []; + + foreach ($this->whereTypes as $types) { + $result = [...$result, ...$types]; + } + + return $result; + } + + public function setSelectIdentifierParams(array $selectIdentifierParams): self + { + $this->selectIdentifierParams = $selectIdentifierParams; + + return $this; + } + + public function setSelectDateParams(array $selectDateParams): self + { + $this->selectDateParams = $selectDateParams; + + return $this; + } + + public function setFrom(string $from): self + { + $this->from = $from; + + return $this; + } + + public function setSelectIdentifierTypes(array $selectIdentifierTypes): self + { + $this->selectIdentifierTypes = $selectIdentifierTypes; + + return $this; + } + + public function setSelectDateTypes(array $selectDateTypes): self + { + $this->selectDateTypes = $selectDateTypes; + + return $this; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQueryInterface.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQueryInterface.php new file mode 100644 index 000000000..e46795457 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQueryInterface.php @@ -0,0 +1,67 @@ + + */ + public function getSelectIdentifierParams(): array; + + /** + * @return list + */ + public function getSelectIdentifiersTypes(): array; + + public function getSelectDate(): string; + + /** + * @return list + */ + public function getSelectDateParams(): array; + + /** + * @return list + */ + public function getSelectDateTypes(): array; + + public function getFromQuery(): string; + + /** + * @return list + */ + public function getFromQueryParams(): array; + + /** + * @return list + */ + public function getFromQueryTypes(): array; + + public function getWhereQuery(): string; + + /** + * @return list + */ + public function getWhereQueryParams(): array; + + /** + * @return list + */ + public function getWhereQueryTypes(): array; +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQueryToSqlBuilder.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQueryToSqlBuilder.php new file mode 100644 index 000000000..2c0c59cff --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/FetchQueryToSqlBuilder.php @@ -0,0 +1,57 @@ +, types: list} + */ + public function toSql(FetchQueryInterface $query): array + { + $sql = strtr(self::SQL, [ + '{{ key }}' => $query->getSelectKeyString(), + '{{ identifiers }}' => $query->getSelectIdentifierJsonB(), + '{{ date }}' => $query->getSelectDate(), + '{{ from }}' => $query->getFromQuery(), + '{{ where }}' => '' === ($w = $query->getWhereQuery()) ? '' : 'WHERE ' . $w, + ]); + + $params = [ + ...$query->getSelectIdentifierParams(), + ...$query->getSelectDateParams(), + ...$query->getFromQueryParams(), + ...$query->getWhereQueryParams() + ]; + + $types = [ + ...$query->getSelectIdentifiersTypes(), + ...$query->getSelectDateTypes(), + ...$query->getFromQueryTypes(), + ...$query->getWhereQueryTypes(), + ]; + + return ['sql' => $sql, 'params' => $params, 'types' => $types]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/GenericDocDTO.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/GenericDocDTO.php new file mode 100644 index 000000000..fe9bf7e4f --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/GenericDocDTO.php @@ -0,0 +1,31 @@ +linked instanceof AccompanyingPeriod ? 'accompanying-period' : 'person'; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/GenericDocForAccompanyingPeriodProviderInterface.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/GenericDocForAccompanyingPeriodProviderInterface.php new file mode 100644 index 000000000..0d3cb1c32 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/GenericDocForAccompanyingPeriodProviderInterface.php @@ -0,0 +1,31 @@ + + */ + private iterable $providersForAccompanyingPeriod, + + /** + * @var iterable + */ + private iterable $providersForPerson, + private Connection $connection, + ) { + $this->builder = new FetchQueryToSqlBuilder(); + } + + /** + * @param list $places + * @throws Exception + */ + public function countDocForAccompanyingPeriod( + AccompanyingPeriod $accompanyingPeriod, + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null, + array $places = [] + ): int { + ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $places); + + return $this->countDoc($sql, $params, $types); + } + + private function countDoc(string $sql, array $params, array $types): int + { + if ($sql === '') { + return 0; + } + + $countSql = "SELECT count(*) AS c FROM ({$sql}) AS sq"; + $result = $this->connection->executeQuery($countSql, $params, $types); + + $number = $result->fetchOne(); + + if (false === $number) { + throw new \UnexpectedValueException("number of documents failed to load"); + } + + return $number; + } + + public function countDocForPerson( + Person $person, + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null, + array $places = [] + ): int { + ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($person, $startDate, $endDate, $content, $places); + + return $this->countDoc($sql, $params, $types); + } + + /** + * @param list $places places to search. When empty, search in all places + * @return iterable + * @throws Exception + */ + public function findDocForAccompanyingPeriod( + AccompanyingPeriod $accompanyingPeriod, + int $offset = 0, + int $limit = 20, + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null, + array $places = [] + ): iterable { + ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod, $startDate, $endDate, $content, $places); + + return $this->findDocs($accompanyingPeriod, $sql, $params, $types, $offset, $limit); + } + + /** + * @throws \JsonException + * @throws Exception + */ + private function findDocs(AccompanyingPeriod|Person $linked, string $sql, array $params, array $types, int $offset, int $limit): iterable + { + if ($sql === '') { + return []; + } + + $runSql = "{$sql} ORDER BY doc_date DESC LIMIT ? OFFSET ?"; + $runParams = [...$params, ...[$limit, $offset]]; + $runTypes = [...$types, ...[Types::INTEGER, Types::INTEGER]]; + + foreach ($this->connection->iterateAssociative($runSql, $runParams, $runTypes) as $row) { + yield new GenericDocDTO( + $row['key'], + json_decode($row['identifiers'], true, 512, JSON_THROW_ON_ERROR), + new \DateTimeImmutable($row['doc_date']), + $linked, + ); + } + } + + /** + * @param list $places places to search. When empty, search in all places + * @return iterable + */ + public function findDocForPerson( + Person $person, + int $offset = 0, + int $limit = 20, + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null, + array $places = [] + ): iterable { + ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($person, $startDate, $endDate, $content, $places); + + return $this->findDocs($person, $sql, $params, $types, $offset, $limit); + } + + public function placesForPerson(Person $person): array + { + ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($person); + + return $this->places($sql, $params, $types); + } + + public function placesForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): array + { + ['sql' => $sql, 'params' => $params, 'types' => $types] = $this->buildUnionQuery($accompanyingPeriod); + + return $this->places($sql, $params, $types); + } + + private function places(string $sql, array $params, array $types): array + { + if ($sql === '') { + return []; + } + + $runSql = "SELECT DISTINCT key FROM ({$sql}) AS sq ORDER BY key"; + + $keys = []; + + foreach ($this->connection->iterateAssociative($runSql, $params, $types) as $k) { + $keys[] = $k['key']; + } + + return $keys; + } + + /** + * @param list $places places to search. When empty, search in all places + */ + private function buildUnionQuery( + AccompanyingPeriod|Person $linked, + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null, + array $places = [], + ): array { + $queries = []; + + if ($linked instanceof AccompanyingPeriod) { + foreach ($this->providersForAccompanyingPeriod as $provider) { + if (!$provider->isAllowedForAccompanyingPeriod($linked)) { + continue; + } + $queries[] = $provider->buildFetchQueryForAccompanyingPeriod($linked, $startDate, $endDate, $content); + } + } else { + foreach ($this->providersForPerson as $provider) { + if (!$provider->isAllowedForPerson($linked)) { + continue; + } + $queries[] = $provider->buildFetchQueryForPerson($linked, $startDate, $endDate, $content); + } + } + $sql = []; + $params = []; + $types = []; + + foreach ($queries as $query) { + if ([] !== $places and !in_array($query->getSelectKeyString(), $places, true)) { + continue; + } + + ['sql' => $q, 'params' => $p, 'types' => $t ] = $this->builder->toSql($query); + + $sql[] = $q; + $params = [...$params, ...$p]; + $types = [...$types, ...$t]; + } + + return ['sql' => implode(' UNION ', $sql), 'params' => $params, 'types' => $types]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/Providers/AccompanyingCourseDocumentGenericDocProvider.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/Providers/AccompanyingCourseDocumentGenericDocProvider.php new file mode 100644 index 000000000..fd36f7976 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/Providers/AccompanyingCourseDocumentGenericDocProvider.php @@ -0,0 +1,147 @@ +entityManager->getClassMetadata(AccompanyingCourseDocument::class); + + $query = new FetchQuery( + self::KEY, + sprintf('jsonb_build_object(\'id\', %s)', $classMetadata->getIdentifierColumnNames()[0]), + $classMetadata->getColumnName('date'), + $classMetadata->getSchemaName() . '.' . $classMetadata->getTableName() + ); + + $query->addWhereClause( + sprintf('%s = ?', $classMetadata->getSingleAssociationJoinColumnName('course')), + [$accompanyingPeriod->getId()], + [Types::INTEGER] + ); + + return $this->addWhereClause($query, $startDate, $endDate, $content); + } + + public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool + { + return $this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $accompanyingPeriod); + } + + public function buildFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface + { + $classMetadata = $this->entityManager->getClassMetadata(AccompanyingCourseDocument::class); + + $query = new FetchQuery( + self::KEY, + sprintf('jsonb_build_object(\'id\', %s)', $classMetadata->getIdentifierColumnNames()[0]), + $classMetadata->getColumnName('date'), + $classMetadata->getSchemaName() . '.' . $classMetadata->getTableName() . ' AS acc_course_document' + ); + + $atLeastOne = false; + $or = []; + $orParams = []; + $orTypes = []; + + foreach ($person->getAccompanyingPeriodParticipations() as $participation) { + if (!$this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $participation->getAccompanyingPeriod())) { + continue; + } + + $atLeastOne = true; + + $or[] = sprintf( + "(acc_course_document.%s = ? AND acc_course_document.%s BETWEEN ? AND COALESCE(?, 'infinity'::date))", + $classMetadata->getSingleAssociationJoinColumnName('course'), + $classMetadata->getColumnName('date') + ); + $orParams = [...$orParams, $participation->getAccompanyingPeriod()->getId(), $participation->getStartDate(), $participation->getEndDate()]; + $orTypes = [...$orTypes, Types::INTEGER, Types::DATE_MUTABLE, Types::DATE_MUTABLE]; + } + + if (!$atLeastOne) { + // there aren't any period allowed to be seen. Add an unreachable condition + $query->addWhereClause('TRUE = FALSE'); + + return $query; + } + + $query->addWhereClause('(' . implode(' OR ', $or) . ')', $orParams, $orTypes); + + return $this->addWhereClause($query, $startDate, $endDate, $content); + } + + public function isAllowedForPerson(Person $person): bool + { + return $this->security->isGranted(AccompanyingPeriodVoter::SEE, $person); + } + + private function addWhereClause(FetchQuery $query, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery + { + $classMetadata = $this->entityManager->getClassMetadata(AccompanyingCourseDocument::class); + + if (null !== $startDate) { + $query->addWhereClause( + sprintf('? <= %s', $classMetadata->getColumnName('date')), + [$startDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $endDate) { + $query->addWhereClause( + sprintf('? >= %s', $classMetadata->getColumnName('date')), + [$endDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $content and '' !== $content) { + $query->addWhereClause( + sprintf( + '(%s ilike ? OR %s ilike ?)', + $classMetadata->getColumnName('title'), + $classMetadata->getColumnName('description') + ), + ['%' . $content . '%', '%' . $content . '%'], + [Types::STRING, Types::STRING] + ); + } + + return $query; + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/Providers/PersonDocumentGenericDocProvider.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/Providers/PersonDocumentGenericDocProvider.php new file mode 100644 index 000000000..613f8d758 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/Providers/PersonDocumentGenericDocProvider.php @@ -0,0 +1,65 @@ +personDocumentACLAwareRepository->buildFetchQueryForPerson( + $person, + $startDate, + $endDate, + $content + ); + } + + public function isAllowedForPerson(Person $person): bool + { + return $this->security->isGranted(PersonDocumentVoter::SEE, $person); + } + + public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface + { + return $this->personDocumentACLAwareRepository->buildFetchQueryForAccompanyingPeriod($accompanyingPeriod, $startDate, $endDate, $content); + } + + public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool + { + // we assume that the user is allowed to see at least one person of the course + // this will be double checked when running the query + return true; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/Renderer/AccompanyingCourseDocumentGenericDocRenderer.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/Renderer/AccompanyingCourseDocumentGenericDocRenderer.php new file mode 100644 index 000000000..c32620030 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/Renderer/AccompanyingCourseDocumentGenericDocRenderer.php @@ -0,0 +1,59 @@ +key === AccompanyingCourseDocumentGenericDocProvider::KEY + || $genericDocDTO->key === PersonDocumentGenericDocProvider::KEY; + } + + public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string + { + return '@ChillDocStore/List/list_item.html.twig'; + } + + public function getTemplateData(GenericDocDTO $genericDocDTO, $options = []): array + { + if (AccompanyingCourseDocumentGenericDocProvider::KEY === $genericDocDTO->key) { + return [ + 'document' => $doc = $this->accompanyingCourseDocumentRepository->find($genericDocDTO->identifiers['id']), + 'accompanyingCourse' => $doc->getCourse(), + 'options' => $options, + 'context' => $genericDocDTO->getContext(), + ]; + } + // this is a person + return [ + 'document' => $doc = $this->personDocumentRepository->find($genericDocDTO->identifiers['id']), + 'person' => $doc->getPerson(), + 'options' => $options, + 'context' => $genericDocDTO->getContext(), + ]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocExtension.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocExtension.php new file mode 100644 index 000000000..308d85cd7 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocExtension.php @@ -0,0 +1,28 @@ + true, + 'is_safe' => ['html'], + ]) + ]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocExtensionRuntime.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocExtensionRuntime.php new file mode 100644 index 000000000..2dee0ed0b --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocExtensionRuntime.php @@ -0,0 +1,50 @@ + + */ + private iterable $renderers, + ) { + } + + /** + * @throws RuntimeError + * @throws SyntaxError + * @throws LoaderError + */ + public function renderGenericDoc(Environment $twig, GenericDocDTO $genericDocDTO, array $options = []): string + { + foreach ($this->renderers as $renderer) { + if ($renderer->supports($genericDocDTO)) { + return $twig->render( + $renderer->getTemplate($genericDocDTO, $options), + $renderer->getTemplateData($genericDocDTO, $options), + ); + } + } + + throw new \LogicException("no renderer found"); + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocRendererInterface.php b/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocRendererInterface.php new file mode 100644 index 000000000..940001f4a --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/GenericDoc/Twig/GenericDocRendererInterface.php @@ -0,0 +1,24 @@ +security->isGranted(AccompanyingCourseDocumentVoter::SEE, $course)) { $menu->addChild($this->translator->trans('Documents'), [ - 'route' => 'accompanying_course_document_index', + 'route' => 'chill_docstore_generic-doc_by-period_index', 'routeParameters' => [ - 'course' => $course->getId(), + 'id' => $course->getId(), ], ]) ->setExtras([ @@ -80,9 +80,9 @@ final class MenuBuilder implements LocalMenuBuilderInterface if ($this->security->isGranted(PersonDocumentVoter::SEE, $person)) { $menu->addChild($this->translator->trans('Documents'), [ - 'route' => 'person_document_index', + 'route' => 'chill_docstore_generic-doc_by-person_index', 'routeParameters' => [ - 'person' => $person->getId(), + 'id' => $person->getId(), ], ]) ->setExtras([ diff --git a/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentACLAwareRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentACLAwareRepository.php index 23dcc4e0b..26a42b894 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentACLAwareRepository.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentACLAwareRepository.php @@ -12,30 +12,33 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Repository; use Chill\DocStoreBundle\Entity\PersonDocument; +use Chill\DocStoreBundle\GenericDoc\FetchQuery; +use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface; +use Chill\DocStoreBundle\GenericDoc\Providers\PersonDocumentGenericDocProvider; use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter; +use Chill\MainBundle\Entity\Scope; +use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; +use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface; +use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; +use Chill\PersonBundle\Entity\AccompanyingPeriod; +use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; use Chill\PersonBundle\Entity\Person; +use DateTimeImmutable; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use Symfony\Component\Security\Core\Security; -class PersonDocumentACLAwareRepository implements PersonDocumentACLAwareRepositoryInterface +final readonly class PersonDocumentACLAwareRepository implements PersonDocumentACLAwareRepositoryInterface { - private AuthorizationHelperInterface $authorizationHelper; - - private CenterResolverDispatcher $centerResolverDispatcher; - - private EntityManagerInterface $em; - - private Security $security; - - public function __construct(EntityManagerInterface $em, AuthorizationHelperInterface $authorizationHelper, CenterResolverDispatcher $centerResolverDispatcher, Security $security) - { - $this->em = $em; - $this->authorizationHelper = $authorizationHelper; - $this->centerResolverDispatcher = $centerResolverDispatcher; - $this->security = $security; + public function __construct( + private EntityManagerInterface $em, + private CenterResolverManagerInterface $centerResolverManager, + private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser, + private Security $security, + ) { } public function buildQueryByPerson(Person $person): QueryBuilder @@ -49,6 +52,128 @@ class PersonDocumentACLAwareRepository implements PersonDocumentACLAwareReposito return $qb; } + + public function buildFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQueryInterface + { + $query = $this->buildBaseFetchQueryForPerson($person, $startDate, $endDate, $content); + + return $this->addFetchQueryByPersonACL($query, $person); + } + + public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $period, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null): FetchQueryInterface + { + $personDocMetadata = $this->em->getClassMetadata(PersonDocument::class); + $participationMetadata = $this->em->getClassMetadata(AccompanyingPeriodParticipation::class); + + $query = new FetchQuery( + PersonDocumentGenericDocProvider::KEY, + sprintf('jsonb_build_object(\'id\', person_document.%s)', $personDocMetadata->getSingleIdentifierColumnName()), + sprintf('person_document.%s', $personDocMetadata->getColumnName('date')), + sprintf('%s AS person_document', $personDocMetadata->getSchemaName().'.'.$personDocMetadata->getTableName()) + ); + + $query->addJoinClause( + sprintf( + 'JOIN %s AS participation ON participation.%s = person_document.%s '. + 'AND person_document.%s BETWEEN participation.%s AND COALESCE(participation.%s, \'infinity\'::date)', + $participationMetadata->getTableName(), + $participationMetadata->getSingleAssociationJoinColumnName('person'), + $personDocMetadata->getSingleAssociationJoinColumnName('person'), + $personDocMetadata->getColumnName('date'), + $participationMetadata->getColumnName('startDate'), + $participationMetadata->getColumnName('endDate') + ) + ); + + $query->addWhereClause( + sprintf('participation.%s = ?', $participationMetadata->getSingleAssociationJoinColumnName('accompanyingPeriod')), + [$period->getId()], + [Types::INTEGER] + ); + + // can we see the document for this person ? + $orPersonId = []; + foreach ($period->getParticipations() as $participation) { + if (!$this->security->isGranted(PersonDocumentVoter::SEE, $participation->getPerson())) { + continue; + } + $orPersonId[] = $participation->getPerson()->getId(); + + } + + if ([] === $orPersonId) { + $query->addWhereClause('FALSE = TRUE'); + + return $query; + } + + $query->addWhereClause( + sprintf( + 'participation.%s IN (%s)', + $participationMetadata->getSingleAssociationJoinColumnName('person'), + implode(', ', array_fill(0, count($orPersonId), '?')) + ), + $orPersonId, + array_fill(0, count($orPersonId), Types::INTEGER) + ); + + return $this->addFilterClauses($query, $startDate, $endDate, $content); + } + + public function buildBaseFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery + { + $personDocMetadata = $this->em->getClassMetadata(PersonDocument::class); + + $query = new FetchQuery( + PersonDocumentGenericDocProvider::KEY, + sprintf('jsonb_build_object(\'id\', person_document.%s)', $personDocMetadata->getSingleIdentifierColumnName()), + sprintf('person_document.%s', $personDocMetadata->getColumnName('date')), + sprintf('%s AS person_document', $personDocMetadata->getSchemaName().'.'.$personDocMetadata->getTableName()) + ); + + $query->addWhereClause( + sprintf('person_document.%s = ?', $personDocMetadata->getSingleAssociationJoinColumnName('person')), + [$person->getId()], + [Types::INTEGER] + ); + + return $this->addFilterClauses($query, $startDate, $endDate, $content); + } + + private function addFilterClauses(FetchQuery $query, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery + { + $personDocMetadata = $this->em->getClassMetadata(PersonDocument::class); + + if (null !== $startDate) { + $query->addWhereClause( + sprintf('? <= %s', $personDocMetadata->getColumnName('date')), + [$startDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $endDate) { + $query->addWhereClause( + sprintf('? >= %s', $personDocMetadata->getColumnName('date')), + [$endDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $content and '' !== $content) { + $query->addWhereClause( + sprintf( + '(%s ilike ? OR %s ilike ?)', + $personDocMetadata->getColumnName('title'), + $personDocMetadata->getColumnName('description') + ), + ['%' . $content . '%', '%' . $content . '%'], + [Types::STRING, Types::STRING] + ); + } + return $query; + } + public function countByPerson(Person $person): int { $qb = $this->buildQueryByPerson($person)->select('COUNT(d)'); @@ -75,16 +200,58 @@ class PersonDocumentACLAwareRepository implements PersonDocumentACLAwareReposito private function addACL(QueryBuilder $qb, Person $person): void { - $center = $this->centerResolverDispatcher->resolveCenter($person); + $reachableScopes = []; - $reachableScopes = $this->authorizationHelper - ->getReachableScopes( - $this->security->getUser(), - PersonDocumentVoter::SEE, - $center - ); + foreach ($this->centerResolverManager->resolveCenters($person) as $center) { + $reachableScopes = [ + ...$reachableScopes, + ...$this->authorizationHelperForCurrentUser + ->getReachableScopes( + PersonDocumentVoter::SEE, + $center + ) + ]; + } + + if ([] === $reachableScopes) { + $qb->andWhere("'FALSE' = 'TRUE'"); + + return; + } $qb->andWhere($qb->expr()->in('d.scope', ':scopes')) ->setParameter('scopes', $reachableScopes); } + + private function addFetchQueryByPersonACL(FetchQuery $fetchQuery, Person $person): FetchQuery + { + $personDocMetadata = $this->em->getClassMetadata(PersonDocument::class); + + $reachableScopes = []; + + foreach ($this->centerResolverManager->resolveCenters($person) as $center) { + $reachableScopes = [ + ...$reachableScopes, + ...$this->authorizationHelperForCurrentUser->getReachableScopes(PersonDocumentVoter::SEE, $center) + ]; + } + + if ([] === $reachableScopes) { + $fetchQuery->addWhereClause('FALSE = TRUE'); + + return $fetchQuery; + } + + $fetchQuery->addWhereClause( + sprintf( + 'person_document.%s IN (%s)', + $personDocMetadata->getSingleAssociationJoinColumnName('scope'), + implode(', ', array_fill(0, count($reachableScopes), '?')) + ), + array_map(static fn (Scope $s) => $s->getId(), $reachableScopes), + array_fill(0, count($reachableScopes), Types::INTEGER) + ); + + return $fetchQuery; + } } diff --git a/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentACLAwareRepositoryInterface.php b/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentACLAwareRepositoryInterface.php index 6c4bd2e9a..f1bc70812 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentACLAwareRepositoryInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentACLAwareRepositoryInterface.php @@ -11,11 +11,33 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Repository; +use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface; +use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; interface PersonDocumentACLAwareRepositoryInterface { + /** + * @deprecated use fetch query for listing and counting person documents + */ public function countByPerson(Person $person): int; + /** + * @deprecated use fetch query for listing and counting person documents + */ public function findByPerson(Person $person, array $orderBy = [], int $limit = 20, int $offset = 0): array; + + public function buildFetchQueryForPerson( + Person $person, + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null + ): FetchQueryInterface; + + public function buildFetchQueryForAccompanyingPeriod( + AccompanyingPeriod $period, + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null + ): FetchQueryInterface; } diff --git a/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentRepository.php new file mode 100644 index 000000000..40afdc220 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Repository/PersonDocumentRepository.php @@ -0,0 +1,57 @@ + + */ +readonly class PersonDocumentRepository implements ObjectRepository +{ + private EntityRepository $repository; + + public function __construct( + private EntityManagerInterface $entityManager + ) { + $this->repository = $this->entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?PersonDocument + { + return $this->repository->find($id); + } + + public function findAll() + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?PersonDocument + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return PersonDocument::class; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/delete.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/delete.html.twig index c9bb608cf..d6f23d09d 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/delete.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/delete.html.twig @@ -31,8 +31,8 @@ 'title' : 'Delete document ?'|trans, 'display_content' : block('docdescription'), 'confirm_question' : 'Are you sure you want to remove this document ?'|trans, - 'cancel_route' : 'accompanying_course_document_index', - 'cancel_parameters' : {'course' : accompanyingCourse.id, 'id': document.id}, + 'cancel_route' : 'chill_docstore_generic-doc_by-period_index', + 'cancel_parameters' : {'id' : accompanyingCourse.id}, 'form' : delete_form } ) }} {% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/edit.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/edit.html.twig index 0ca5661fc..326814502 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/edit.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/edit.html.twig @@ -21,7 +21,7 @@ diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/index.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/index.html.twig deleted file mode 100644 index 7a013260c..000000000 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/index.html.twig +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} - -{% set activeRouteKey = '' %} - -{% block title %} - {{ 'Documents' }} -{% endblock %} - -{% block js %} - {{ parent() }} - {{ encore_entry_script_tags('mod_docgen_picktemplate') }} - {{ encore_entry_script_tags('mod_entity_workflow_pick') }} - {{ encore_entry_script_tags('mod_document_action_buttons_group') }} -{% endblock %} - -{% block css %} - {{ parent() }} - {{ encore_entry_link_tags('mod_docgen_picktemplate') }} - {{ encore_entry_link_tags('mod_entity_workflow_pick') }} - {{ encore_entry_link_tags('mod_document_action_buttons_group') }} -{% endblock %} - -{% block content %} -
-

{{ 'Documents' }}

- - {% if documents|length == 0 %} -

{{ 'No documents'|trans }}

- {% else %} -
- {% for document in documents %} - {% include '@ChillDocStore/List/list_item.html.twig' %} - {% endfor %} -
- {% endif %} - - {{ chill_pagination(pagination) }} - -
- - {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %} - - {% endif %} - -
-{% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/new.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/new.html.twig index 01be1a5d7..3fb692c78 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/new.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/new.html.twig @@ -25,7 +25,7 @@
  • - + {{ 'Back to the list' | trans }}
  • diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/show.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/show.html.twig index 3c62451a9..9590dbb78 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/show.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/show.html.twig @@ -46,7 +46,7 @@
    • - + {{ 'Back to the list' | trans }}
    • diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/accompanying_period_list.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/accompanying_period_list.html.twig new file mode 100644 index 000000000..b22c7d00f --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/accompanying_period_list.html.twig @@ -0,0 +1,54 @@ +{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} + +{% set activeRouteKey = '' %} + +{% block title %} + {{ 'Documents' }} +{% endblock %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_docgen_picktemplate') }} + {{ encore_entry_script_tags('mod_entity_workflow_pick') }} + {{ encore_entry_script_tags('mod_document_action_buttons_group') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_script_tags('mod_docgen_picktemplate') }} + {{ encore_entry_link_tags('mod_entity_workflow_pick') }} + {{ encore_entry_link_tags('mod_document_action_buttons_group') }} +{% endblock %} + +{% block content %} +
      +

      {{ 'Documents' }}

      + + {{ filter|chill_render_filter_order_helper }} + + {% if documents|length == 0 %} +

      {{ 'No documents'|trans }}

      + {% else %} +
      + {% for document in documents %} + {{ document|chill_generic_doc_render }} + {% endfor %} +
      + {% endif %} + + {{ chill_pagination(pagination) }} + +
      + + {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %} + + {% endif %} + +
      +{% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/index.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/person_list.html.twig similarity index 95% rename from src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/index.html.twig rename to src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/person_list.html.twig index 8d201605e..a4aa4fbbc 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/index.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/GenericDoc/person_list.html.twig @@ -44,12 +44,14 @@

      {{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}

      + {{ filter|chill_render_filter_order_helper }} + {% if documents|length == 0 %}

      {{ 'No documents'|trans }}

      {% else %}
      {% for document in documents %} - {% include 'ChillDocStoreBundle:List:list_item.html.twig' %} + {{ document|chill_generic_doc_render }} {% endfor %}
      {% endif %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/List/list_item.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/List/list_item.html.twig index 8dea4bbe7..58504b095 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/List/list_item.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/List/list_item.html.twig @@ -10,8 +10,23 @@ {% elseif document.object.isFailure %}
      {{ 'docgen.Doc generation failed'|trans }}
      {% endif %} + + {% if context == 'person' and accompanyingCourse is defined %} +
      + + {{ accompanyingCourse.id }} +   +
      + {% elseif context == 'accompanying-period' and person is defined %} +
      + + {{ 'Document from person %name%'|trans({ '%name%': document.person|chill_entity_render_string }) }} +   +
      + + {% endif %}
      - {{ document.title }} + {{ document.title|chill_print_or_message("No title") }}
      {% if document.object.type is not empty %}
      diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/delete.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/delete.html.twig index bfdd87dc3..41c229faa 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/delete.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/delete.html.twig @@ -36,8 +36,8 @@ 'title' : 'Delete document ?'|trans, 'display_content' : block('docdescription'), 'confirm_question' : 'Are you sure you want to remove this document ?'|trans, - 'cancel_route' : 'person_document_index', - 'cancel_parameters' : {'person' : person.id, 'id': document.id}, + 'cancel_route' : 'chill_docstore_generic-doc_by-person_index', + 'cancel_parameters' : {'id' : person.id}, 'form' : delete_form } ) }} {% endblock %} diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/edit.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/edit.html.twig index 416a35e35..17ce9d774 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/edit.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/edit.html.twig @@ -38,7 +38,7 @@
      • - + {{ 'Back to the list' | trans }}
      • diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/new.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/new.html.twig index 3187714a6..faf8895e7 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/new.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/new.html.twig @@ -42,7 +42,7 @@
        • - + {{ 'Back to the list' | trans }}
        • diff --git a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/show.html.twig b/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/show.html.twig index c276e067e..1c0aa5e64 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/show.html.twig +++ b/src/Bundle/ChillDocStoreBundle/Resources/views/PersonDocument/show.html.twig @@ -63,7 +63,7 @@
          • - + {{ 'Back to the list' | trans }}
          • diff --git a/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/FetchQueryToSqlBuilderTest.php b/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/FetchQueryToSqlBuilderTest.php new file mode 100644 index 000000000..02be9460f --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/FetchQueryToSqlBuilderTest.php @@ -0,0 +1,89 @@ +addJoinClause('LEFT JOIN other b ON a.id = b.foreign_id', ['foo'], [Types::STRING]); + $index = $query->addJoinClause('LEFT JOIN other c ON a.id = c.foreign_id', ['bar'], [Types::STRING]); + $query->addJoinClause('LEFT JOIN other d ON a.id = d.foreign_id', ['bar_baz'], [Types::STRING]); + $query->removeJoinClause($index); + $query->addWhereClause('b.item = ?', ['baz'], [Types::STRING]); + $index = $query->addWhereClause('b.cancel', [ 'foz'], [Types::STRING]); + $query->removeWhereClause($index); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $filteredSql = + implode(" ", array_filter( + explode(" ", str_replace("\n", "", $sql)), + fn (string $tok) => $tok !== "" + )) + ; + + self::assertEquals( + "SELECT 'test' AS key, jsonb_build_object('id', a.column) AS identifiers, ". + "a.datecolumn::date AS doc_date FROM my_table a LEFT JOIN other b ON a.id = b.foreign_id LEFT JOIN other d ON a.id = d.foreign_id WHERE b.item = ?", + $filteredSql + ); + self::assertEquals(['foo', 'bar_baz', 'baz'], $params); + self::assertEquals([Types::STRING, Types::STRING, Types::STRING], $types); + + + + } + + public function testToSqlWithoutWhere(): void + { + $query = new FetchQuery( + 'test', + 'jsonb_build_object(\'id\', a.column)', + 'a.datecolumn', + 'my_table a' + ); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $filteredSql = + implode(" ", array_filter( + explode(" ", str_replace("\n", "", $sql)), + fn (string $tok) => $tok !== "" + )) + ; + + self::assertEquals( + "SELECT 'test' AS key, jsonb_build_object('id', a.column) AS identifiers, ". + "a.datecolumn::date AS doc_date FROM my_table a", + $filteredSql + ); + self::assertEquals([], $params); + self::assertEquals([], $types); + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/ManagerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/ManagerTest.php new file mode 100644 index 000000000..f59779374 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/ManagerTest.php @@ -0,0 +1,217 @@ +em = self::$container->get(EntityManagerInterface::class); + $this->connection = self::$container->get(Connection::class); + } + + public function testCountByAccompanyingPeriod(): void + { + $period = $this->em->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $period) { + throw new \UnexpectedValueException("period not found"); + } + + $manager = new Manager( + [new SimpleGenericDocAccompanyingPeriodProvider()], + [new SimpleGenericDocPersonProvider()], + $this->connection, + ); + + $nb = $manager->countDocForAccompanyingPeriod($period); + + self::assertIsInt($nb); + } + + public function testCountByPerson(): void + { + $person = $this->em->createQuery('SELECT a FROM '.Person::class.' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $person) { + throw new \UnexpectedValueException("person found"); + } + + $manager = new Manager( + [new SimpleGenericDocAccompanyingPeriodProvider()], + [new SimpleGenericDocPersonProvider()], + $this->connection, + ); + + $nb = $manager->countDocForPerson($person); + + self::assertIsInt($nb); + } + + public function testFindDocByAccompanyingPeriod(): void + { + $period = $this->em->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $period) { + throw new \UnexpectedValueException("period not found"); + } + + $manager = new Manager( + [new SimpleGenericDocAccompanyingPeriodProvider()], + [new SimpleGenericDocPersonProvider()], + $this->connection, + ); + + foreach ($manager->findDocForAccompanyingPeriod($period) as $doc) { + self::assertInstanceOf(GenericDocDTO::class, $doc); + } + } + + public function testFindDocByPerson(): void + { + $person = $this->em->createQuery('SELECT a FROM '.Person::class.' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $person) { + throw new \UnexpectedValueException("person not found"); + } + + $manager = new Manager( + [new SimpleGenericDocAccompanyingPeriodProvider()], + [new SimpleGenericDocPersonProvider()], + $this->connection, + ); + + foreach ($manager->findDocForPerson($person) as $doc) { + self::assertInstanceOf(GenericDocDTO::class, $doc); + } + } + + public function testPlacesForPerson(): void + { + $person = $this->em->createQuery('SELECT a FROM '.Person::class.' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $person) { + throw new \UnexpectedValueException("person not found"); + } + + $manager = new Manager( + [new SimpleGenericDocAccompanyingPeriodProvider()], + [new SimpleGenericDocPersonProvider()], + $this->connection, + ); + + $places = $manager->placesForPerson($person); + + self::assertEquals(['dummy_person_doc'], $places); + } + + public function testPlacesForAccompanyingPeriod(): void + { + $period = $this->em->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $period) { + throw new \UnexpectedValueException("period not found"); + } + + $manager = new Manager( + [new SimpleGenericDocAccompanyingPeriodProvider()], + [new SimpleGenericDocPersonProvider()], + $this->connection, + ); + + $places = $manager->placesForAccompanyingPeriod($period); + + self::assertEquals(['accompanying_course_document_dummy'], $places); + } +} + +final readonly class SimpleGenericDocAccompanyingPeriodProvider implements GenericDocForAccompanyingPeriodProviderInterface +{ + public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface + { + $query = new FetchQuery( + 'accompanying_course_document_dummy', + sprintf('jsonb_build_object(\'id\', %s)', 'id'), + 'd', + '(VALUES (1, \'2023-05-01\'::date), (2, \'2023-05-01\'::date)) AS sq (id, d)', + ); + + $query->addWhereClause('d > ?', [new \DateTimeImmutable('2023-01-01')], [Types::DATE_IMMUTABLE]); + + return $query; + } + + public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool + { + return true; + } +} + +final readonly class SimpleGenericDocPersonProvider implements GenericDocForPersonProviderInterface +{ + public function buildFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface + { + $query = new FetchQuery( + 'dummy_person_doc', + sprintf('jsonb_build_object(\'id\', %s)', 'id'), + 'd', + '(VALUES (1, \'2023-05-01\'::date), (2, \'2023-05-01\'::date)) AS sq (id, d)', + ); + + $query->addWhereClause('d > ?', [new \DateTimeImmutable('2023-01-01')], [Types::DATE_IMMUTABLE]); + + return $query; + } + + public function isAllowedForPerson(Person $person): bool + { + return true; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/Providers/AccompanyingCourseDocumentGenericDocProviderTest.php b/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/Providers/AccompanyingCourseDocumentGenericDocProviderTest.php new file mode 100644 index 000000000..79ced9258 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/Providers/AccompanyingCourseDocumentGenericDocProviderTest.php @@ -0,0 +1,79 @@ +entityManager = self::$container->get(EntityManagerInterface::class); + } + + /** + * @dataProvider provideSearchArguments + */ + public function testWithoutAnyArgument(?\DateTimeImmutable $startDate, ?\DateTimeImmutable $endDate, ?string $content = null): void + { + $period = $this->entityManager->createQuery('SELECT a FROM '.AccompanyingPeriod::class.' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $period) { + throw new \UnexpectedValueException("period not found"); + } + + $security = $this->prophesize(Security::class); + $security->isGranted(AccompanyingCourseDocumentVoter::SEE, $period) + ->willReturn(true); + + $provider = new AccompanyingCourseDocumentGenericDocProvider( + $security->reveal(), + $this->entityManager + ); + + $query = $provider->buildFetchQueryForAccompanyingPeriod($period, $startDate, $endDate, $content); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection()->executeQuery('SELECT COUNT(*) FROM ('.$sql.') AS sq', $params, $types) + ->fetchOne(); + + self::assertIsInt($nb); + } + + public function provideSearchArguments(): iterable + { + yield [null, null, null]; + yield [new \DateTimeImmutable('1 month ago'), null, null]; + yield [new \DateTimeImmutable('1 month ago'), new \DateTimeImmutable('now'), null]; + yield [null, null, 'test']; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/Providers/PersonDocumentGenericDocProviderTest.php b/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/Providers/PersonDocumentGenericDocProviderTest.php new file mode 100644 index 000000000..577357820 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/GenericDoc/Providers/PersonDocumentGenericDocProviderTest.php @@ -0,0 +1,84 @@ +entityManager = self::$container->get(EntityManagerInterface::class); + $this->personDocumentACLAwareRepository = self::$container->get(PersonDocumentACLAwareRepositoryInterface::class); + } + + /** + * @dataProvider provideDataBuildFetchQueryForPerson + * @throws \Doctrine\DBAL\Exception + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function testBuildFetchQueryForPerson(?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?string $content): void + { + $security = $this->prophesize(Security::class); + $person = $this->entityManager->createQuery('SELECT a FROM '.Person::class.' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $person) { + throw new \UnexpectedValueException("person found"); + } + + $provider = new PersonDocumentGenericDocProvider( + $security->reveal(), + $this->personDocumentACLAwareRepository + ); + + $query = $provider->buildFetchQueryForPerson($person, $startDate, $endDate, $content); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection() + ->fetchOne("SELECT COUNT(*) AS nb FROM (${sql}) AS sq", $params, $types); + + self::assertIsInt($nb, "Test that the query is syntactically correct"); + } + + public function provideDataBuildFetchQueryForPerson(): iterable + { + yield [null, null, null]; + yield [new DateTimeImmutable('1 year ago'), null, null]; + yield [null, new DateTimeImmutable('1 year ago'), null]; + yield [new DateTimeImmutable('2 years ago'), new DateTimeImmutable('1 year ago'), null]; + yield [null, null, 'test']; + yield [new DateTimeImmutable('2 years ago'), new DateTimeImmutable('1 year ago'), 'test']; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Repository/PersonDocumentACLAwareRepositoryTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Repository/PersonDocumentACLAwareRepositoryTest.php new file mode 100644 index 000000000..fd611042c --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Repository/PersonDocumentACLAwareRepositoryTest.php @@ -0,0 +1,158 @@ +entityManager = self::$container->get(EntityManagerInterface::class); + $this->scopeRepository = self::$container->get(ScopeRepositoryInterface::class); + } + + /** + * @dataProvider provideDataBuildFetchQueryForPerson + * @throws \Doctrine\DBAL\Exception + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function testBuildFetchQueryForPerson(?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): void + { + $centerManager = $this->prophesize(CenterResolverManagerInterface::class); + $centerManager->resolveCenters(Argument::type(Person::class)) + ->willReturn([new Center()]); + + $scopes = $this->scopeRepository->findAll(); + $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); + $authorizationHelper->getReachableScopes(PersonDocumentVoter::SEE, Argument::any())->willReturn($scopes); + + $repository = new PersonDocumentACLAwareRepository( + $this->entityManager, + $centerManager->reveal(), + $authorizationHelper->reveal(), + $this->prophesize(Security::class)->reveal() + ); + + $person = $this->entityManager->createQuery("SELECT p FROM " . Person::class . " p") + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $person) { + throw new \RuntimeException("person not exists in database"); + } + + $query = $repository->buildFetchQueryForPerson($person, $startDate, $endDate, $content); + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection() + ->fetchOne("SELECT COUNT(*) FROM ({$sql}) AS sq", $params, $types); + + self::assertIsInt($nb, "test that the query could be executed"); + } + + /** + * @dataProvider provideDateForFetchQueryForAccompanyingPeriod + */ + public function testBuildFetchQueryForAccompanyingPeriod( + AccompanyingPeriod $period, + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null + ): void { + $centerManager = $this->prophesize(CenterResolverManagerInterface::class); + $centerManager->resolveCenters(Argument::type(Person::class)) + ->willReturn([new Center()]); + + $scopes = $this->scopeRepository->findAll(); + $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); + $authorizationHelper->getReachableScopes(PersonDocumentVoter::SEE, Argument::any())->willReturn($scopes); + + $security = $this->prophesize(Security::class); + $security->isGranted(PersonDocumentVoter::SEE, Argument::type(Person::class))->willReturn(true); + + $repository = new PersonDocumentACLAwareRepository( + $this->entityManager, + $centerManager->reveal(), + $authorizationHelper->reveal(), + $security->reveal() + ); + + $query = $repository->buildFetchQueryForAccompanyingPeriod($period, $startDate, $endDate, $content); + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection() + ->fetchOne("SELECT COUNT(*) FROM ({$sql}) AS sq", $params, $types); + + self::assertIsInt($nb, "test that the query could be executed"); + } + + public function provideDateForFetchQueryForAccompanyingPeriod(): iterable + { + $this->setUp(); + + if (null === $period = $this->entityManager->createQuery( + "SELECT p FROM " . AccompanyingPeriod::class . " p WHERE SIZE(p.participations) > 0" + ) + ->setMaxResults(1)->getSingleResult()) { + throw new \RuntimeException("no course found"); + } + + yield [$period, null, null, null]; + yield [$period, new DateTimeImmutable('1 year ago'), null, null]; + yield [$period, null, new DateTimeImmutable('1 year ago'), null]; + yield [$period, new DateTimeImmutable('2 years ago'), new DateTimeImmutable('1 year ago'), null]; + yield [$period, null, null, 'test']; + yield [$period, new DateTimeImmutable('2 years ago'), new DateTimeImmutable('1 year ago'), 'test']; + + } + + public function provideDataBuildFetchQueryForPerson(): iterable + { + yield [null, null, null]; + yield [new DateTimeImmutable('1 year ago'), null, null]; + yield [null, new DateTimeImmutable('1 year ago'), null]; + yield [new DateTimeImmutable('2 years ago'), new DateTimeImmutable('1 year ago'), null]; + yield [null, null, 'test']; + yield [new DateTimeImmutable('2 years ago'), new DateTimeImmutable('1 year ago'), 'test']; + } + +} diff --git a/src/Bundle/ChillDocStoreBundle/config/services.yaml b/src/Bundle/ChillDocStoreBundle/config/services.yaml index 860495677..04fc3ace3 100644 --- a/src/Bundle/ChillDocStoreBundle/config/services.yaml +++ b/src/Bundle/ChillDocStoreBundle/config/services.yaml @@ -2,46 +2,72 @@ parameters: # cl_chill_person.example.class: Chill\PersonBundle\Example services: - Chill\DocStoreBundle\Repository\: - autowire: true - autoconfigure: true - resource: "../Repository/" + Chill\DocStoreBundle\Repository\: + autowire: true + autoconfigure: true + resource: "../Repository/" - Chill\DocStoreBundle\Form\DocumentCategoryType: - class: Chill\DocStoreBundle\Form\DocumentCategoryType - arguments: ["%kernel.bundles%"] - tags: - - { name: form.type } + Chill\DocStoreBundle\Form\DocumentCategoryType: + class: Chill\DocStoreBundle\Form\DocumentCategoryType + arguments: [ "%kernel.bundles%" ] + tags: + - { name: form.type } - Chill\DocStoreBundle\Form\PersonDocumentType: - class: Chill\DocStoreBundle\Form\PersonDocumentType - autowire: true - autoconfigure: true - # arguments: - # - "@chill.main.helper.translatable_string" - tags: - - { name: form.type, alias: chill_docstorebundle_form_document } + Chill\DocStoreBundle\Form\PersonDocumentType: + class: Chill\DocStoreBundle\Form\PersonDocumentType + autowire: true + autoconfigure: true + # arguments: + # - "@chill.main.helper.translatable_string" + tags: + - { name: form.type, alias: chill_docstorebundle_form_document } - Chill\DocStoreBundle\Security\Authorization\: - resource: "./../Security/Authorization" - autowire: true - autoconfigure: true - tags: - - { name: chill.role } + Chill\DocStoreBundle\Security\Authorization\: + resource: "./../Security/Authorization" + autowire: true + autoconfigure: true + tags: + - { name: chill.role } - Chill\DocStoreBundle\Workflow\: - resource: './../Workflow/' - autoconfigure: true - autowire: true + Chill\DocStoreBundle\Workflow\: + resource: './../Workflow/' + autoconfigure: true + autowire: true - Chill\DocStoreBundle\Serializer\Normalizer\: - autowire: true - resource: '../Serializer/Normalizer/' - tags: - - { name: 'serializer.normalizer', priority: 16 } + Chill\DocStoreBundle\Serializer\Normalizer\: + autowire: true + resource: '../Serializer/Normalizer/' + tags: + - { name: 'serializer.normalizer', priority: 16 } - Chill\DocStoreBundle\Service\: - autowire: true - autoconfigure: true - resource: '../Service/' + Chill\DocStoreBundle\Service\: + autowire: true + autoconfigure: true + resource: '../Service/' + Chill\DocStoreBundle\GenericDoc\Manager: + autowire: true + autoconfigure: true + arguments: + $providersForAccompanyingPeriod: !tagged_iterator chill_doc_store.generic_doc_accompanying_period_provider + $providersForPerson: !tagged_iterator chill_doc_store.generic_doc_person_provider + + Chill\DocStoreBundle\GenericDoc\Twig\GenericDocExtension: + autoconfigure: true + autowire: true + + Chill\DocStoreBundle\GenericDoc\Twig\GenericDocExtensionRuntime: + autoconfigure: true + autowire: true + arguments: + $renderers: !tagged_iterator chill_doc_store.generic_doc_renderer + + Chill\DocStoreBundle\GenericDoc\Providers\: + autowire: true + autoconfigure: true + resource: '../GenericDoc/Providers/' + + Chill\DocStoreBundle\GenericDoc\Renderer\: + autowire: true + autoconfigure: true + resource: '../GenericDoc/Renderer/' diff --git a/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml b/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml index 0f8124d41..ffabdaa59 100644 --- a/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillDocStoreBundle/translations/messages.fr.yml @@ -18,11 +18,19 @@ No document found: Aucun document trouvé The document is successfully registered: Le document est enregistré The document is successfully updated: Le document est mis à jour Any description: Aucune description +Document from person %name%: Document de l'usager %name% See the document: Voir le document document: Any title: Aucun titre +generic_doc: + filter: + keys: + accompanying_course_document: Document du parcours + person_document: Documents de l'usager + date-range: Date du document + # delete Delete document ?: Supprimer le document ? Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ? diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php index 367cc0861..94aa3c50b 100644 --- a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php @@ -91,6 +91,11 @@ class FilterOrderHelper return $this->checkboxes; } + public function hasCheckBox(string $name): bool + { + return array_key_exists($name, $this->checkboxes); + } + /** * @return array<'to': DateTimeImmutable, 'from': DateTimeImmutable> */ diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index bbe3d23fb..6a6136a41 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -42,6 +42,7 @@ by_user: "par " lifecycleUpdate: Evenements de création et mise à jour address_fields: Données liées à l'adresse Datas: Données +No title: Aucun titre inactive: inactif diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index accf39db5..ba2fca2af 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -271,6 +271,7 @@ class AccompanyingPeriod implements * cascade={"persist", "refresh", "remove", "merge", "detach"}) * @Groups({"read", "docgen:read"}) * @ParticipationOverlap(groups={AccompanyingPeriod::STEP_DRAFT, AccompanyingPeriod::STEP_CONFIRMED}) + * @var Collection */ private Collection $participations; @@ -873,6 +874,7 @@ class AccompanyingPeriod implements /** * Get Participations Collection. + * @return Collection */ public function getParticipations(): Collection { diff --git a/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/accompanying_period_work.scss b/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/accompanying_period_work.scss index a12f1554c..e82029a1f 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/accompanying_period_work.scss +++ b/src/Bundle/ChillPersonBundle/Resources/public/chill/scss/accompanying_period_work.scss @@ -1,3 +1,25 @@ +.badge-accompanying-work-type { + display: inline-block; + background-color: #f3f3f3; + + .title_label { + @include chill_badge(#e2793d); + } + + .title_action { + padding: var(--bs-badge-padding-y) var(--bs-badge-padding-x); + margin-right: 1rem; + + font-size: var(--bs-badge-font-size); + font-weight: var(--bs-badge-font-weight); + line-height: 1; + color: var(--bs-badge-color); + text-align: center; + white-space: nowrap; + vertical-align: baseline; + } +} + /// AccompanyingCourse Work Pages div.accompanying-course-work { diff --git a/src/Bundle/ChillPersonBundle/Resources/views/GenericDoc/evaluation_document.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/GenericDoc/evaluation_document.html.twig new file mode 100644 index 000000000..ae54d077b --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/views/GenericDoc/evaluation_document.html.twig @@ -0,0 +1,76 @@ +{% import "@ChillDocStore/Macro/macro.html.twig" as m %} +{% import "@ChillDocStore/Macro/macro_mimeicon.html.twig" as mm %} +{% import '@ChillPerson/Macro/updatedBy.html.twig' as mmm %} + +{% set w = document.accompanyingPeriodWorkEvaluation.accompanyingPeriodWork %} + +
            +
            +
            + {% if document.storedObject.isPending %} +
            {{ 'docgen.Doc generation is pending'|trans }}
            + {% elseif document.storedObject.isFailure %} +
            {{ 'docgen.Doc generation failed'|trans }}
            + {% endif %} +
            + {% if context == 'person' %} + + {{ w.accompanyingPeriod.id }} +   + {% endif %} +
            + + {{ w.socialAction|chill_entity_render_string }} > {{ document.accompanyingPeriodWorkEvaluation.evaluation.title|localize_translatable_string }} +
            +
            +
            + {{ document.title|chill_print_or_message("No title") }} +
            + {% if document.storedObject.type is not empty %} +
            + {{ mm.mimeIcon(document.storedObject.type) }} +
            + {% endif %} + {% if document.storedObject.hasTemplate %} +
            +

            {{ document.storedObject.template.name|localize_translatable_string }}

            +
            + {% endif %} +
            + +
            +
            +
            + {{ document.storedObject.createdAt|format_date('short') }} +
            +
            +
            +
            + +
            +
            + {{ mmm.createdBy(document) }} +
            +
              +
            • + {{ chill_entity_workflow_list('Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument', document.id) }} +
            • + {% if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_DOCUMENT_SHOW', document) %} +
            • + {{ document.storedObject|chill_document_button_group(document.title, is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', document.accompanyingPeriodWorkEvaluation.accompanyingPeriodWork)) }} +
            • + {% endif %} + {% if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_SEE', w)%} +
            • + +
            • + {% endif %} + {% if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', w) %} +
            • + +
            • + {% endif %} +
            + +
            +
            diff --git a/src/Bundle/ChillPersonBundle/Service/GenericDoc/Providers/AccompanyingPeriodWorkEvaluationGenericDocProvider.php b/src/Bundle/ChillPersonBundle/Service/GenericDoc/Providers/AccompanyingPeriodWorkEvaluationGenericDocProvider.php new file mode 100644 index 000000000..f8b99a048 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Service/GenericDoc/Providers/AccompanyingPeriodWorkEvaluationGenericDocProvider.php @@ -0,0 +1,165 @@ +entityManager->getClassMetadata(AccompanyingPeriod\AccompanyingPeriodWork::class); + $query = $this->buildBaseQuery(); + + $query->addWhereClause( + sprintf('action.%s = ?', $accompanyingPeriodWorkMetadata->getAssociationMapping('accompanyingPeriod')['joinColumns'][0]['name']), + [$accompanyingPeriod->getId()], + [Types::INTEGER] + ); + + return $this->addWhereClausesToQuery($query, $startDate, $endDate, $content); + } + + public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool + { + return $this->security->isGranted(AccompanyingPeriodWorkVoter::SEE, $accompanyingPeriod); + } + + private function addWhereClausesToQuery(FetchQuery $query, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery + { + $classMetadata = $this->entityManager->getClassMetadata(AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument::class); + $storedObjectMetadata = $this->entityManager->getClassMetadata(StoredObject::class); + + if (null !== $startDate) { + $query->addWhereClause( + sprintf('doc_store.%s >= ?', $storedObjectMetadata->getColumnName('createdAt')), + [$startDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $endDate) { + $query->addWhereClause( + sprintf('doc_store.%s < ?', $storedObjectMetadata->getColumnName('createdAt')), + [$endDate], + [Types::DATE_IMMUTABLE] + ); + } + + if (null !== $content) { + $query->addWhereClause( + sprintf('apwed.%s ilike ?', $classMetadata->getColumnName('title')), + ['%' . $content . '%'], + [Types::STRING] + ); + } + + return $query; + } + + public function buildFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface + { + $storedObjectMetadata = $this->entityManager->getClassMetadata(StoredObject::class); + $accompanyingPeriodWorkMetadata = $this->entityManager->getClassMetadata(AccompanyingPeriod\AccompanyingPeriodWork::class); + $query = $this->buildBaseQuery(); + + // we loop over each accompanying period participation, to check of the user is allowed to see them + $or = []; + $orParams = []; + $orTypes = []; + foreach ($person->getAccompanyingPeriodParticipations() as $participation) { + if (!$this->security->isGranted(AccompanyingPeriodWorkVoter::SEE, $participation->getAccompanyingPeriod())) { + continue; + } + + $or[] = sprintf( + '(action.%s = ? AND apwed.%s BETWEEN ?::date AND COALESCE(?::date, \'infinity\'::date))', + $accompanyingPeriodWorkMetadata->getSingleAssociationJoinColumnName('accompanyingPeriod'), + $storedObjectMetadata->getColumnName('createdAt') + ); + $orParams = [...$orParams, $participation->getAccompanyingPeriod()->getId(), + DateTimeImmutable::createFromInterface($participation->getStartDate()), + null === $participation->getEndDate() ? null : DateTimeImmutable::createFromInterface($participation->getEndDate())]; + $orTypes = [...$orTypes, Types::INTEGER, Types::DATE_IMMUTABLE, Types::DATE_IMMUTABLE]; + } + + if ([] === $or) { + $query->addWhereClause('TRUE = FALSE'); + + return $query; + } + + $query->addWhereClause(sprintf('(%s)', implode(' OR ', $or)), $orParams, $orTypes); + + return $this->addWhereClausesToQuery($query, $startDate, $endDate, $content); + } + + public function isAllowedForPerson(Person $person): bool + { + // this will be filtered during query + return true; + } + + private function buildBaseQuery(): FetchQuery + { + $classMetadata = $this->entityManager->getClassMetadata(AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument::class); + $storedObjectMetadata = $this->entityManager->getClassMetadata(StoredObject::class); + $evaluationMetadata = $this->entityManager->getClassMetadata(AccompanyingPeriod\AccompanyingPeriodWorkEvaluation::class); + $accompanyingPeriodWorkMetadata = $this->entityManager->getClassMetadata(AccompanyingPeriod\AccompanyingPeriodWork::class); + + $query = new FetchQuery( + self::KEY, + sprintf("jsonb_build_object('id', apwed.%s)", $classMetadata->getColumnName('id')), + sprintf('apwed.'.$storedObjectMetadata->getColumnName('createdAt')), + $classMetadata->getTableName().' AS apwed' + ); + $query->addJoinClause(sprintf( + 'JOIN %s doc_store ON doc_store.%s = apwed.%s', + $storedObjectMetadata->getSchemaName().'.'.$storedObjectMetadata->getTableName(), + $storedObjectMetadata->getColumnName('id'), + $classMetadata->getSingleAssociationJoinColumnName('storedObject') + )); + $query->addJoinClause(sprintf( + 'JOIN %s evaluation ON apwed.%s = evaluation.%s', + $evaluationMetadata->getTableName(), + $classMetadata->getAssociationMapping('accompanyingPeriodWorkEvaluation')['joinColumns'][0]['name'], + $evaluationMetadata->getColumnName('id') + )); + $query->addJoinClause(sprintf( + 'JOIN %s action ON evaluation.%s = action.%s', + $accompanyingPeriodWorkMetadata->getTableName(), + $evaluationMetadata->getAssociationMapping('accompanyingPeriodWork')['joinColumns'][0]['name'], + $accompanyingPeriodWorkMetadata->getColumnName('id') + )); + + return $query; + } +} diff --git a/src/Bundle/ChillPersonBundle/Service/GenericDoc/Renderer/AccompanyingPeriodWorkEvaluationGenericDocRenderer.php b/src/Bundle/ChillPersonBundle/Service/GenericDoc/Renderer/AccompanyingPeriodWorkEvaluationGenericDocRenderer.php new file mode 100644 index 000000000..9810fe7a1 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Service/GenericDoc/Renderer/AccompanyingPeriodWorkEvaluationGenericDocRenderer.php @@ -0,0 +1,44 @@ +key === AccompanyingPeriodWorkEvaluationGenericDocProvider::KEY; + } + + public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string + { + return '@ChillPerson/GenericDoc/evaluation_document.html.twig'; + } + + public function getTemplateData(GenericDocDTO $genericDocDTO, $options = []): array + { + return [ + 'document' => $this->accompanyingPeriodWorkEvaluationDocumentRepository->find($genericDocDTO->identifiers['id']), + 'context' => $genericDocDTO->getContext(), + ]; + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/AccompanyingPeriodCalendarGenericDocProviderTest.php b/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/AccompanyingPeriodCalendarGenericDocProviderTest.php new file mode 100644 index 000000000..8aff23673 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/AccompanyingPeriodCalendarGenericDocProviderTest.php @@ -0,0 +1,134 @@ +security = self::$container->get(Security::class); + $this->entityManager = self::$container->get(EntityManagerInterface::class); + } + + /** + * @dataProvider provideDataForAccompanyingPeriod + */ + public function testBuildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate, ?\DateTimeImmutable $endDate, ?string $content): void + { + $provider = new AccompanyingPeriodCalendarGenericDocProvider($this->security, $this->entityManager); + + $query = $provider->buildFetchQueryForAccompanyingPeriod($accompanyingPeriod, $startDate, $endDate, $content); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection()->fetchOne("SELECT COUNT(*) FROM ({$sql}) AS sq", $params, $types); + + self::assertIsInt($nb); + } + + /** + * @dataProvider provideDataForPerson + */ + public function testBuildFetchQueryForPerson(Person $person, ?\DateTimeImmutable $startDate, ?\DateTimeImmutable $endDate, ?string $content): void + { + $security = $this->prophesize(Security::class); + $security->isGranted(CalendarVoter::SEE, Argument::any())->willReturn(true); + + $provider = new AccompanyingPeriodCalendarGenericDocProvider($security->reveal(), $this->entityManager); + + $query = $provider->buildFetchQueryForPerson($person, $startDate, $endDate, $content); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection()->fetchOne("SELECT COUNT(*) FROM ({$sql}) AS sq", $params, $types); + + self::assertIsInt($nb); + self::assertStringNotContainsStringIgnoringCase('FALSE = TRUE', $sql); + self::assertStringNotContainsStringIgnoringCase('TRUE = FALSE', $sql); + } + + /** + * @dataProvider provideDataForPerson + */ + public function testBuildFetchQueryForPersonWithoutAnyRight(Person $person, ?\DateTimeImmutable $startDate, ?\DateTimeImmutable $endDate, ?string $content): void + { + $security = $this->prophesize(Security::class); + $security->isGranted(CalendarVoter::SEE, Argument::any())->willReturn(false); + + $provider = new AccompanyingPeriodCalendarGenericDocProvider($security->reveal(), $this->entityManager); + + $query = $provider->buildFetchQueryForPerson($person, $startDate, $endDate, $content); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection()->fetchOne("SELECT COUNT(*) FROM ({$sql}) AS sq", $params, $types); + + self::assertIsInt($nb); + self::assertStringContainsStringIgnoringCase('TRUE = FALSE', $sql); + } + + public function provideDataForPerson(): iterable + { + $this->setUp(); + + if (null === $person = $this->entityManager->createQuery("SELECT p FROM " . Person::class . " p WHERE SIZE(p.accompanyingPeriodParticipations) > 0") + ->setMaxResults(1)->getSingleResult()) { + throw new \RuntimeException("There is no person"); + } + + yield [$person, null, null, null]; + yield [$person, new \DateTimeImmutable("1 year ago"), null, null]; + yield [$person, new \DateTimeImmutable("1 year ago"), new \DateTimeImmutable("6 month ago"), null]; + yield [$person, new \DateTimeImmutable("1 year ago"), new \DateTimeImmutable("6 month ago"), "text"]; + yield [$person, null, null, "text"]; + yield [$person, null, new \DateTimeImmutable("6 month ago"), null]; + } + + public function provideDataForAccompanyingPeriod(): iterable + { + $this->setUp(); + + if (null === $period = $this->entityManager->createQuery("SELECT p FROM " . AccompanyingPeriod::class . " p ") + ->setMaxResults(1)->getSingleResult()) { + throw new \RuntimeException("There is no accompanying period"); + } + + yield [$period, null, null, null]; + yield [$period, new \DateTimeImmutable("1 year ago"), null, null]; + yield [$period, new \DateTimeImmutable("1 year ago"), new \DateTimeImmutable("6 month ago"), null]; + yield [$period, new \DateTimeImmutable("1 year ago"), new \DateTimeImmutable("6 month ago"), "text"]; + yield [$period, null, null, "text"]; + yield [$period, null, new \DateTimeImmutable("6 month ago"), null]; + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/AccompanyingPeriodWorkEvaluationGenericDocProviderTest.php b/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/AccompanyingPeriodWorkEvaluationGenericDocProviderTest.php new file mode 100644 index 000000000..c9ed96581 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/AccompanyingPeriodWorkEvaluationGenericDocProviderTest.php @@ -0,0 +1,79 @@ +entityManager = self::$container->get(EntityManagerInterface::class); + } + + /** + * @dataProvider provideSearchArguments + */ + public function testBuildFetchQueryForAccompanyingPeriod( + ?\DateTimeImmutable $startDate = null, + ?\DateTimeImmutable $endDate = null, + ?string $content = null + ): void { + $period = $this->entityManager->createQuery("SELECT a FROM " . AccompanyingPeriod::class . ' a') + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $period) { + throw new \RuntimeException('no accompanying period in databasee'); + } + + $security = $this->prophesize(Security::class); + + $provider = new AccompanyingPeriodWorkEvaluationGenericDocProvider( + $security->reveal(), + $this->entityManager + ); + + $query = $provider->buildFetchQueryForAccompanyingPeriod($period, $startDate, $endDate, $content); + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection()->executeQuery( + 'SELECT COUNT(*) FROM ('.$sql.') AS sq', + $params, + $types + )->fetchOne(); + + self::assertIsInt($nb, "Test that there are no errors"); + } + + public function provideSearchArguments(): iterable + { + yield [null, null, null]; + yield [new \DateTimeImmutable('1 month ago'), null, null]; + yield [new \DateTimeImmutable('1 month ago'), new \DateTimeImmutable('now'), null]; + yield [null, null, 'test']; + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/PersonCalendarGenericDocProviderTest.php b/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/PersonCalendarGenericDocProviderTest.php new file mode 100644 index 000000000..dbad052ca --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Service/GenericDoc/Providers/PersonCalendarGenericDocProviderTest.php @@ -0,0 +1,70 @@ +security = self::$container->get(Security::class); + $this->entityManager = self::$container->get(EntityManagerInterface::class); + } + + /** + * @dataProvider provideDataForPerson + */ + public function testBuildFetchQueryForPerson(Person $person, ?\DateTimeImmutable $startDate, ?\DateTimeImmutable $endDate, ?string $content): void + { + $provider = new PersonCalendarGenericDocProvider($this->security, $this->entityManager); + + $query = $provider->buildFetchQueryForPerson($person, $startDate, $endDate, $content); + + ['sql' => $sql, 'params' => $params, 'types' => $types] = (new FetchQueryToSqlBuilder())->toSql($query); + + $nb = $this->entityManager->getConnection()->fetchOne("SELECT COUNT(*) FROM ({$sql}) AS sq", $params, $types); + + self::assertIsInt($nb); + } + + public function provideDataForPerson(): iterable + { + $this->setUp(); + + if (null === $person = $this->entityManager->createQuery("SELECT p FROM " . Person::class . " p ") + ->setMaxResults(1)->getSingleResult()) { + throw new \RuntimeException("There is no person"); + } + + yield [$person, null, null, null]; + yield [$person, new \DateTimeImmutable("1 year ago"), null, null]; + yield [$person, new \DateTimeImmutable("1 year ago"), new \DateTimeImmutable("6 month ago"), null]; + yield [$person, new \DateTimeImmutable("1 year ago"), new \DateTimeImmutable("6 month ago"), "text"]; + yield [$person, null, null, "text"]; + yield [$person, null, new \DateTimeImmutable("6 month ago"), null]; + } +} diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index 188c31f1a..12647d04c 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -1236,3 +1236,8 @@ social_action: social_issue: and children: et dérivés + +generic_doc: + filter: + keys: + accompanying_period_work_evaluation_document: Document des actions d'accompagnement