Merge branch '103-document-page' into 'master'

Liste unifiée des documents

Closes #103

See merge request Chill-Projet/chill-bundles!545
This commit is contained in:
Julien Fastré 2023-06-27 16:35:29 +00:00
commit 5bbc50976e
72 changed files with 4087 additions and 221 deletions

View File

@ -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"

View File

@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Repository;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\ActivityBundle\Service\GenericDoc\Providers\AccompanyingPeriodActivityGenericDocProvider;
use Chill\ActivityBundle\Service\GenericDoc\Providers\PersonActivityGenericDocProvider;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\Providers\PersonDocumentGenericDocProvider;
use Chill\DocStoreBundle\Repository\PersonDocumentACLAwareRepositoryInterface;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodWorkVoter;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\MappingException;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpKernel\HttpCache\Store;
use Symfony\Component\Security\Core\Security;
final readonly class ActivityDocumentACLAwareRepository implements ActivityDocumentACLAwareRepositoryInterface
{
public function __construct(
private EntityManagerInterface $em,
private CenterResolverManagerInterface $centerResolverManager,
private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser,
private Security $security
) {
}
public function buildFetchQueryActivityDocumentLinkedToPersonFromPersonContext(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQueryInterface
{
$query = $this->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;
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Repository;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\PersonBundle\Entity\Person;
use DateTimeImmutable;
/**
* Gives queries usable for fetching documents, with ACL aware
*/
interface ActivityDocumentACLAwareRepositoryInterface
{
/**
* Return a fetch query for querying document's activities for a person
*
* This method must check the rights to see a document: the user must be allowed to see the given activities
*/
public function buildFetchQueryActivityDocumentLinkedToPersonFromPersonContext(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQueryInterface;
/**
* Return a fetch query for querying document's activities for an activity in accompanying periods, but for a given person
*
* This method must check the rights to see a document: the user must be allowed to see the given accompanying periods
*/
public function buildFetchQueryActivityDocumentLinkedToAccompanyingPeriodFromPersonContext(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null): FetchQuery;
}

View File

@ -1,5 +1,7 @@
// Access to Bootstrap variables and mixins // Access to Bootstrap variables and mixins
@import '~ChillMainAssets/module/bootstrap/shared'; @import '~ChillMainAssets/module/bootstrap/shared';
@import '~ChillPersonAssets/chill/scss/mixins.scss';
@import 'bootstrap/scss/_badge.scss';
//// ACTIVITY CREATION //// ACTIVITY CREATION
// first step: select type page // first step: select type page
@ -96,3 +98,25 @@ li.document-list-item {
justify-content: space-between; justify-content: space-between;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
.badge-activity-type {
display: inline-block;
background-color: #f3f3f3;
.title_label {
@include chill_badge(#9acd32);
}
.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;
}
}

View File

@ -0,0 +1,83 @@
{% 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 person_id = null %}
{% if activity.person %}
{% set person_id = activity.person.id %}
{% endif %}
{% set accompanying_course_id = null %}
{% if activity.accompanyingPeriod %}
{% set accompanying_course_id = activity.accompanyingPeriod.id %}
{% endif %}
<div class="item-bloc activity-item{% if itemBlocClass is defined %} {{ itemBlocClass }}{% endif %}">
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div>
{% if activity.accompanyingPeriod is not null and context == 'person' %}
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ activity.accompanyingPeriod.id }}
</span>&nbsp;
{% endif %}
<div class="badge-activity-type">
<span class="title_label"></span>
<span class="title_action">
{{ activity.type.name | localize_translatable_string }}
{% if activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</div>
</div>
<div class="denomination h2">
{{ document.title|chill_print_or_message("No title") }}
</div>
{% if document.hasTemplate %}
<div>
<p>{{ document.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
<div class="dates row text-end">
<span>{{ document.createdAt|format_date('short') }}</span>
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
{% if is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %}
<li>
{{ document|chill_document_button_group(document.title, is_granted('CHILL_ACTIVITY_UPDATE', activity), {small: false}) }}
</li>
{% endif %}
{% if is_granted('CHILL_ACTIVITY_SEE', activity)%}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': activity.id, 'person_id': person_id, 'accompanying_period_id': accompanying_course_id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACTIVITY_UPDATE', activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_edit', {'id': activity.id, 'person_id': person_id, 'accompanying_period_id': accompanying_course_id }) }}" class="btn btn-edit"></a>
</li>
{% endif %}
</ul>
</div>
</div>

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Service\GenericDoc\Providers;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Repository\ActivityDocumentACLAwareRepositoryInterface;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\MappingException;
use Symfony\Component\Security\Core\Security;
final class AccompanyingPeriodActivityGenericDocProvider implements GenericDocForAccompanyingPeriodProviderInterface, GenericDocForPersonProviderInterface
{
public const KEY = 'accompanying_period_activity_document';
public function __construct(
private EntityManagerInterface $em,
private Security $security,
private ActivityDocumentACLAwareRepositoryInterface $activityDocumentACLAwareRepository,
) {
}
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface
{
$storedObjectMetadata = $this->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);
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Service\GenericDoc\Providers;
use Chill\ActivityBundle\Repository\ActivityDocumentACLAwareRepository;
use Chill\ActivityBundle\Repository\ActivityDocumentACLAwareRepositoryInterface;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentACLAwareRepositoryInterface;
use Chill\PersonBundle\Entity\Person;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Security;
final readonly class PersonActivityGenericDocProvider implements GenericDocForPersonProviderInterface
{
public const KEY = 'person_activity_document';
public function __construct(
private Security $security,
private ActivityDocumentACLAwareRepositoryInterface $personActivityDocumentACLAwareRepository,
) {
}
public function buildFetchQueryForPerson(Person $person, ?DateTimeImmutable $startDate = null, ?DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface
{
return $this->personActivityDocumentACLAwareRepository->buildFetchQueryActivityDocumentLinkedToPersonFromPersonContext(
$person,
$startDate,
$endDate,
$content
);
}
/**
* @param Person $person
* @return bool
*/
public function isAllowedForPerson(Person $person): bool
{
return $this->security->isGranted(ActivityVoter::SEE, $person);
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Service\GenericDoc\Renderers;
use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\ActivityBundle\Service\GenericDoc\Providers\AccompanyingPeriodActivityGenericDocProvider;
use Chill\ActivityBundle\Service\GenericDoc\Providers\PersonActivityGenericDocProvider;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\Twig\GenericDocRendererInterface;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
final class AccompanyingPeriodActivityGenericDocRenderer implements GenericDocRendererInterface
{
private StoredObjectRepository $objectRepository;
private ActivityRepository $activityRepository;
public function __construct(StoredObjectRepository $storedObjectRepository, ActivityRepository $activityRepository)
{
$this->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(),
];
}
}

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Tests\Repository;
use Chill\ActivityBundle\Repository\ActivityDocumentACLAwareRepository;
use Chill\ActivityBundle\Security\Authorization\ActivityVoter;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use phpseclib3\Math\BinaryField;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class ActivityDocumentACLAwareRepositoryTest extends KernelTestCase
{
use ProphecyTrait;
private EntityManagerInterface $entityManager;
private CenterResolverManagerInterface $centerResolverManager;
private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser;
private Security $security;
protected function setUp(): void
{
self::bootKernel();
$this->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"];
}
}

View File

@ -38,3 +38,6 @@ services:
Chill\ActivityBundle\Service\EntityInfo\: Chill\ActivityBundle\Service\EntityInfo\:
resource: '../Service/EntityInfo/' resource: '../Service/EntityInfo/'
Chill\ActivityBundle\Service\GenericDoc\:
resource: '../Service/GenericDoc/'

View File

@ -372,3 +372,8 @@ export:
is sent: envoyé is sent: envoyé
is received: reçu is received: reçu
Group activity by sentreceived: Grouper les échanges par envoyé / 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

View File

@ -0,0 +1 @@
import './scss/badge.scss';

View File

@ -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;
}
}

View File

@ -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 %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.storedObject.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.storedObject.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.storedObject.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div>
{% if c.accompanyingPeriod is not null and context == 'person' %}
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ c.accompanyingPeriod.id }}
</span>&nbsp;
{% endif %}
<span class="badge-calendar">
<span class="title_label"></span>
<span class="title_action">
{{ '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 %}
</span>
</span>
</div>
<div class="denomination h2">
{{ document.storedObject.title|chill_print_or_message("No title") }}
</div>
{% if document.storedObject.hasTemplate %}
<div>
<p>{{ document.storedObject.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
<div class="dates row text-end">
<span>{{ document.storedObject.createdAt|format_date('short') }}</span>
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
{% if is_granted('CHILL_CALENDAR_DOC_SEE', document) %}
<li>
{{ document.storedObject|chill_document_button_group(document.storedObject.title, is_granted('CHILL_CALENDAR_CALENDAR_EDIT', c)) }}
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', c) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', {'id': c.id, 'docId': document.id}) }}" class="btn btn-edit"></a>
</li>
{% endif %}
</ul>
</div>
</div>

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Service\GenericDoc\Providers;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarDoc;
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\MappingException;
use Service\GenericDoc\Providers\AccompanyingPeriodCalendarGenericDocProviderTest;
use Symfony\Component\Security\Core\Security;
/**
* @see AccompanyingPeriodCalendarGenericDocProviderTest
*/
final readonly class AccompanyingPeriodCalendarGenericDocProvider implements GenericDocForAccompanyingPeriodProviderInterface, GenericDocForPersonProviderInterface
{
public const KEY = 'accompanying_period_calendar_document';
public function __construct(
private Security $security,
private EntityManagerInterface $em
) {
}
/**
* @throws MappingException
*/
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\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->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;
}
}

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Service\GenericDoc\Providers;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarDoc;
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodWorkVoter;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\MappingException;
use Service\GenericDoc\Providers\PersonCalendarGenericDocProviderTest;
use Symfony\Component\Security\Core\Security;
/**
* Provide calendar documents for calendar associated to persons
*
* @see PersonCalendarGenericDocProviderTest
*/
final readonly class PersonCalendarGenericDocProvider implements GenericDocForPersonProviderInterface
{
public const KEY = 'person_calendar_document';
public function __construct(
private Security $security,
private EntityManagerInterface $em
) {
}
private function addWhereClausesToQuery(FetchQuery $query, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null): 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;
}
/**
* @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);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Service\GenericDoc\Renderers;
use Chill\CalendarBundle\Repository\CalendarDocRepository;
use Chill\CalendarBundle\Service\GenericDoc\Providers\AccompanyingPeriodCalendarGenericDocProvider;
use Chill\CalendarBundle\Service\GenericDoc\Providers\PersonCalendarGenericDocProvider;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\Twig\GenericDocRendererInterface;
final class AccompanyingPeriodCalendarGenericDocRenderer implements GenericDocRendererInterface
{
private CalendarDocRepository $repository;
public function __construct(CalendarDocRepository $calendarDocRepository)
{
$this->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(),
];
}
}

View File

@ -1,6 +1,8 @@
// this file loads all assets from the Chill calendar bundle // this file loads all assets from the Chill calendar bundle
module.exports = function(encore, entries) { module.exports = function(encore, entries) {
entries.push(__dirname + '/Resources/public/chill/chill.js');
encore.addAliases({ encore.addAliases({
ChillCalendarAssets: __dirname + '/Resources/public' ChillCalendarAssets: __dirname + '/Resources/public'
}); });

View File

@ -43,6 +43,7 @@ crud:
title_edit: Modifier le motif d'annulation title_edit: Modifier le motif d'annulation
chill_calendar: chill_calendar:
Document: Document d'un rendez-vous
form: form:
The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement. 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 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 Document outdated: La date et l'heure du rendez-vous ont été modifiés après la création du document
remote_ms_graph: remote_ms_graph:
freebusy_statuses: freebusy_statuses:
busy: Occupé 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_DELETE: Supprimer les rendez-vous
CHILL_CALENDAR_CALENDAR_SEE: Voir 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

View File

@ -11,8 +11,21 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle; 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; use Symfony\Component\HttpKernel\Bundle\Bundle;
class ChillDocStoreBundle extends 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');
}
} }

View File

@ -39,10 +39,6 @@ class DocumentAccompanyingCourseController extends AbstractController
protected TranslatorInterface $translator; protected TranslatorInterface $translator;
private AccompanyingCourseDocumentRepository $courseRepository;
private PaginatorFactory $paginatorFactory;
/** /**
* DocumentAccompanyingCourseController constructor. * DocumentAccompanyingCourseController constructor.
*/ */
@ -50,14 +46,10 @@ class DocumentAccompanyingCourseController extends AbstractController
TranslatorInterface $translator, TranslatorInterface $translator,
EventDispatcherInterface $eventDispatcher, EventDispatcherInterface $eventDispatcher,
AuthorizationHelper $authorizationHelper, AuthorizationHelper $authorizationHelper,
PaginatorFactory $paginatorFactory,
AccompanyingCourseDocumentRepository $courseRepository
) { ) {
$this->translator = $translator; $this->translator = $translator;
$this->eventDispatcher = $eventDispatcher; $this->eventDispatcher = $eventDispatcher;
$this->authorizationHelper = $authorizationHelper; $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->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( 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") * @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')); $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()) { if ($form->isSubmitted() && !$form->isValid()) {

View File

@ -45,10 +45,6 @@ class DocumentPersonController extends AbstractController
protected TranslatorInterface $translator; protected TranslatorInterface $translator;
private PaginatorFactory $paginatorFactory;
private PersonDocumentACLAwareRepositoryInterface $personDocumentACLAwareRepository;
/** /**
* DocumentPersonController constructor. * DocumentPersonController constructor.
*/ */
@ -56,14 +52,10 @@ class DocumentPersonController extends AbstractController
TranslatorInterface $translator, TranslatorInterface $translator,
EventDispatcherInterface $eventDispatcher, EventDispatcherInterface $eventDispatcher,
AuthorizationHelper $authorizationHelper, AuthorizationHelper $authorizationHelper,
PaginatorFactory $paginatorFactory,
PersonDocumentACLAwareRepositoryInterface $personDocumentACLAwareRepository
) { ) {
$this->translator = $translator; $this->translator = $translator;
$this->eventDispatcher = $eventDispatcher; $this->eventDispatcher = $eventDispatcher;
$this->authorizationHelper = $authorizationHelper; $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->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( 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") * @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')); $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()) { if ($form->isSubmitted() && !$form->isValid()) {

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Symfony\Bundle\TwigBundle\TwigEngine;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Templating\EngineInterface;
final readonly class GenericDocForAccompanyingPeriodController
{
public function __construct(
private FilterOrderHelperFactory $filterOrderHelperFactory,
private Manager $manager,
private PaginatorFactory $paginator,
private Security $security,
private EngineInterface $twig,
) {
}
/**
* @param AccompanyingPeriod $accompanyingPeriod
* @return Response
* @throws \Doctrine\DBAL\Exception
*
* @Route("/{_locale}/doc-store/generic-doc/by-period/{id}/index", name="chill_docstore_generic-doc_by-period_index")
*/
public function list(AccompanyingPeriod $accompanyingPeriod): Response
{
if (!$this->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,
]
));
}
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory;
use Chill\PersonBundle\Entity\Person;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Templating\EngineInterface;
final readonly class GenericDocForPerson
{
public function __construct(
private FilterOrderHelperFactory $filterOrderHelperFactory,
private Manager $manager,
private PaginatorFactory $paginator,
private Security $security,
private EngineInterface $twig,
) {
}
/**
* @throws \Doctrine\DBAL\Exception
*
* @Route("/{_locale}/doc-store/generic-doc/by-person/{id}/index", name="chill_docstore_generic-doc_by-person_index")
*/
public function list(Person $person): Response
{
if (!$this->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,
]
));
}
}

View File

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Doctrine\DBAL\Types\Types;
class FetchQuery implements FetchQueryInterface
{
/**
* @var list<string>
*/
private array $joins = [];
/**
* @var list<list<mixed>>
*/
private array $joinParams = [];
/**
* @var array<list<Types::*>>
*/
private array $joinTypes = [];
/**
* @var array<string>
*/
private array $wheres = [];
/**
* @var array<list<mixed>>
*/
private array $whereParams = [];
/**
* @var array<list<Types::*>>
*/
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;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Doctrine\DBAL\Types\Types;
interface FetchQueryInterface
{
public function getSelectKeyString(): string;
public function getSelectIdentifierJsonB(): string;
/**
* @return list<mixed>
*/
public function getSelectIdentifierParams(): array;
/**
* @return list<Types::*>
*/
public function getSelectIdentifiersTypes(): array;
public function getSelectDate(): string;
/**
* @return list<mixed>
*/
public function getSelectDateParams(): array;
/**
* @return list<Types::*>
*/
public function getSelectDateTypes(): array;
public function getFromQuery(): string;
/**
* @return list<mixed>
*/
public function getFromQueryParams(): array;
/**
* @return list<Types::*>
*/
public function getFromQueryTypes(): array;
public function getWhereQuery(): string;
/**
* @return list<mixed>
*/
public function getWhereQueryParams(): array;
/**
* @return list<Types::*>
*/
public function getWhereQueryTypes(): array;
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Doctrine\DBAL\Types\Types;
final readonly class FetchQueryToSqlBuilder
{
private const SQL = <<<'SQL'
SELECT
'{{ key }}' AS key,
{{ identifiers }} AS identifiers,
{{ date }}::date AS doc_date
FROM {{ from }}
{{ where }}
SQL;
/**
* @param FetchQueryInterface $query
* @return array{sql: string, params: list<mixed>, types: list<Types::*>}
*/
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];
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
final readonly class GenericDocDTO
{
public function __construct(
public string $key,
public array $identifiers,
public \DateTimeImmutable $docDate,
public AccompanyingPeriod|Person $linked,
) {
}
public function getContext(): string
{
return $this->linked instanceof AccompanyingPeriod ? 'accompanying-period' : 'person';
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
interface GenericDocForAccompanyingPeriodProviderInterface
{
public function buildFetchQueryForAccompanyingPeriod(
AccompanyingPeriod $accompanyingPeriod,
?\DateTimeImmutable $startDate = null,
?\DateTimeImmutable $endDate = null,
?string $content = null,
?string $origin = null
): FetchQueryInterface;
/**
* Return true if the user is allowed to see some documents for this provider.
*/
public function isAllowedForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod): bool;
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Chill\PersonBundle\Entity\Person;
use DateTimeImmutable;
interface GenericDocForPersonProviderInterface
{
public function buildFetchQueryForPerson(
Person $person,
?DateTimeImmutable $startDate = null,
?DateTimeImmutable $endDate = null,
?string $content = null,
?string $origin = null
): FetchQueryInterface;
/**
* Return true if the user is allowed to see some documents for this provider.
*/
public function isAllowedForPerson(Person $person): bool;
}

View File

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Types\Types;
final readonly class Manager
{
private FetchQueryToSqlBuilder $builder;
public function __construct(
/**
* @var iterable<GenericDocForAccompanyingPeriodProviderInterface>
*/
private iterable $providersForAccompanyingPeriod,
/**
* @var iterable<GenericDocForPersonProviderInterface>
*/
private iterable $providersForPerson,
private Connection $connection,
) {
$this->builder = new FetchQueryToSqlBuilder();
}
/**
* @param list<string> $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<string> $places places to search. When empty, search in all places
* @return iterable<GenericDocDTO>
* @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<string> $places places to search. When empty, search in all places
* @return iterable<GenericDocDTO>
*/
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<string> $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];
}
}

View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc\Providers;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Security;
final readonly class AccompanyingCourseDocumentGenericDocProvider implements GenericDocForAccompanyingPeriodProviderInterface, GenericDocForPersonProviderInterface
{
public const KEY = 'accompanying_course_document';
public function __construct(
private Security $security,
private EntityManagerInterface $entityManager,
) {
}
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\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()
);
$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;
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc\Providers;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentACLAwareRepositoryInterface;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use DateTimeImmutable;
use Symfony\Component\Security\Core\Security;
final readonly class PersonDocumentGenericDocProvider implements GenericDocForPersonProviderInterface, GenericDocForAccompanyingPeriodProviderInterface
{
public const KEY = 'person_document';
public function __construct(
private Security $security,
private PersonDocumentACLAwareRepositoryInterface $personDocumentACLAwareRepository,
) {
}
public function buildFetchQueryForPerson(
Person $person,
?DateTimeImmutable $startDate = null,
?DateTimeImmutable $endDate = null,
?string $content = null,
?string $origin = null
): FetchQueryInterface {
return $this->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;
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc\Renderer;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\Providers\PersonDocumentGenericDocProvider;
use Chill\DocStoreBundle\GenericDoc\Twig\GenericDocRendererInterface;
use Chill\DocStoreBundle\GenericDoc\Providers\AccompanyingCourseDocumentGenericDocProvider;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
final readonly class AccompanyingCourseDocumentGenericDocRenderer implements GenericDocRendererInterface
{
public function __construct(
private AccompanyingCourseDocumentRepository $accompanyingCourseDocumentRepository,
private PersonDocumentRepository $personDocumentRepository,
) {
}
public function supports(GenericDocDTO $genericDocDTO, $options = []): bool
{
return $genericDocDTO->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(),
];
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
final class GenericDocExtension extends AbstractExtension
{
public function getFilters()
{
return [
new TwigFilter('chill_generic_doc_render', [GenericDocExtensionRuntime::class, 'renderGenericDoc'], [
'needs_environment' => true,
'is_safe' => ['html'],
])
];
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc\Twig;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Extension\RuntimeExtensionInterface;
final readonly class GenericDocExtensionRuntime implements RuntimeExtensionInterface
{
public function __construct(
/**
* @var list<GenericDocRendererInterface>
*/
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");
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\GenericDoc\Twig;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
interface GenericDocRendererInterface
{
public function supports(GenericDocDTO $genericDocDTO, $options = []): bool;
public function getTemplate(GenericDocDTO $genericDocDTO, $options = []): string;
public function getTemplateData(GenericDocDTO $genericDocDTO, $options = []): array;
}

View File

@ -62,9 +62,9 @@ final class MenuBuilder implements LocalMenuBuilderInterface
if ($this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $course)) { if ($this->security->isGranted(AccompanyingCourseDocumentVoter::SEE, $course)) {
$menu->addChild($this->translator->trans('Documents'), [ $menu->addChild($this->translator->trans('Documents'), [
'route' => 'accompanying_course_document_index', 'route' => 'chill_docstore_generic-doc_by-period_index',
'routeParameters' => [ 'routeParameters' => [
'course' => $course->getId(), 'id' => $course->getId(),
], ],
]) ])
->setExtras([ ->setExtras([
@ -80,9 +80,9 @@ final class MenuBuilder implements LocalMenuBuilderInterface
if ($this->security->isGranted(PersonDocumentVoter::SEE, $person)) { if ($this->security->isGranted(PersonDocumentVoter::SEE, $person)) {
$menu->addChild($this->translator->trans('Documents'), [ $menu->addChild($this->translator->trans('Documents'), [
'route' => 'person_document_index', 'route' => 'chill_docstore_generic-doc_by-person_index',
'routeParameters' => [ 'routeParameters' => [
'person' => $person->getId(), 'id' => $person->getId(),
], ],
]) ])
->setExtras([ ->setExtras([

View File

@ -12,30 +12,33 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository; namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\PersonDocument; 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\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\Authorization\AuthorizationHelperInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; 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 Chill\PersonBundle\Entity\Person;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
class PersonDocumentACLAwareRepository implements PersonDocumentACLAwareRepositoryInterface final readonly class PersonDocumentACLAwareRepository implements PersonDocumentACLAwareRepositoryInterface
{ {
private AuthorizationHelperInterface $authorizationHelper; public function __construct(
private EntityManagerInterface $em,
private CenterResolverDispatcher $centerResolverDispatcher; private CenterResolverManagerInterface $centerResolverManager,
private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser,
private EntityManagerInterface $em; private Security $security,
) {
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 buildQueryByPerson(Person $person): QueryBuilder public function buildQueryByPerson(Person $person): QueryBuilder
@ -49,6 +52,128 @@ class PersonDocumentACLAwareRepository implements PersonDocumentACLAwareReposito
return $qb; 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 public function countByPerson(Person $person): int
{ {
$qb = $this->buildQueryByPerson($person)->select('COUNT(d)'); $qb = $this->buildQueryByPerson($person)->select('COUNT(d)');
@ -75,16 +200,58 @@ class PersonDocumentACLAwareRepository implements PersonDocumentACLAwareReposito
private function addACL(QueryBuilder $qb, Person $person): void private function addACL(QueryBuilder $qb, Person $person): void
{ {
$center = $this->centerResolverDispatcher->resolveCenter($person); $reachableScopes = [];
$reachableScopes = $this->authorizationHelper foreach ($this->centerResolverManager->resolveCenters($person) as $center) {
$reachableScopes = [
...$reachableScopes,
...$this->authorizationHelperForCurrentUser
->getReachableScopes( ->getReachableScopes(
$this->security->getUser(),
PersonDocumentVoter::SEE, PersonDocumentVoter::SEE,
$center $center
); )
];
}
if ([] === $reachableScopes) {
$qb->andWhere("'FALSE' = 'TRUE'");
return;
}
$qb->andWhere($qb->expr()->in('d.scope', ':scopes')) $qb->andWhere($qb->expr()->in('d.scope', ':scopes'))
->setParameter('scopes', $reachableScopes); ->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;
}
} }

View File

@ -11,11 +11,33 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository; namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
interface PersonDocumentACLAwareRepositoryInterface interface PersonDocumentACLAwareRepositoryInterface
{ {
/**
* @deprecated use fetch query for listing and counting person documents
*/
public function countByPerson(Person $person): int; 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 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;
} }

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
use UnexpectedValueException;
/**
* @template ObjectRepository<PersonDocument::class>
*/
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;
}
}

View File

@ -31,8 +31,8 @@
'title' : 'Delete document ?'|trans, 'title' : 'Delete document ?'|trans,
'display_content' : block('docdescription'), 'display_content' : block('docdescription'),
'confirm_question' : 'Are you sure you want to remove this document ?'|trans, 'confirm_question' : 'Are you sure you want to remove this document ?'|trans,
'cancel_route' : 'accompanying_course_document_index', 'cancel_route' : 'chill_docstore_generic-doc_by-period_index',
'cancel_parameters' : {'course' : accompanyingCourse.id, 'id': document.id}, 'cancel_parameters' : {'id' : accompanyingCourse.id},
'form' : delete_form 'form' : delete_form
} ) }} } ) }}
{% endblock %} {% endblock %}

View File

@ -21,7 +21,7 @@
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li class="cancel"> <li class="cancel">
<a href="{{ path('accompanying_course_document_index', {'course': accompanyingCourse.id}) }}" class="btn btn-cancel"> <a href="{{ chill_return_path_or('chill_docstore_generic-doc_by-period_index', {'id': accompanyingCourse.id}) }}" class="btn btn-cancel">
{{ 'Back to the list' | trans }} {{ 'Back to the list' | trans }}
</a> </a>
</li> </li>
@ -31,7 +31,7 @@
</li> </li>
{% endif %} {% endif %}
<li class="edit"> <li class="edit">
<button class="btn btn-edit">{{ 'Edit'|trans }}</button> <button class="btn btn-save">{{ 'Save'|trans }}</button>
</li> </li>
</ul> </ul>

View File

@ -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 %}
<div class="document-list">
<h1>{{ 'Documents' }}</h1>
{% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
{% else %}
<div class="flex-table chill-task-list">
{% for document in documents %}
{% include '@ChillDocStore/List/list_item.html.twig' %}
{% endfor %}
</div>
{% endif %}
{{ chill_pagination(pagination) }}
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod" data-entity-id="{{ accompanyingCourse.id }}"></div>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a href="{{ path('accompanying_course_document_new', {'course': accompanyingCourse.id}) }}" class="btn btn-create">
{{ 'Create'|trans }}
</a>
</li>
</ul>
{% endif %}
</div>
{% endblock %}

View File

@ -25,7 +25,7 @@
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li class="cancel"> <li class="cancel">
<a href="{{ path('accompanying_course_document_index', {'course': accompanyingCourse.id}) }}" class="btn btn-cancel"> <a href="{{ chill_return_path_or('chill_docstore_generic-doc_by-period_index', {'id': accompanyingCourse.id}) }}" class="btn btn-cancel">
{{ 'Back to the list' | trans }} {{ 'Back to the list' | trans }}
</a> </a>
</li> </li>

View File

@ -46,7 +46,7 @@
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li class="cancel"> <li class="cancel">
<a href="{{ path('accompanying_course_document_index', {'course': accompanyingCourse.id}) }}" class="btn btn-cancel"> <a href="{{ chill_return_path_or('chill_docstore_generic-doc_by-period_index', {'id': accompanyingCourse.id}) }}" class="btn btn-cancel">
{{ 'Back to the list' | trans }} {{ 'Back to the list' | trans }}
</a> </a>
</li> </li>

View File

@ -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 %}
<div class="document-list">
<h1>{{ 'Documents' }}</h1>
{{ filter|chill_render_filter_order_helper }}
{% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
{% else %}
<div class="flex-table chill-task-list">
{% for document in documents %}
{{ document|chill_generic_doc_render }}
{% endfor %}
</div>
{% endif %}
{{ chill_pagination(pagination) }}
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\PersonBundle\Entity\AccompanyingPeriod" data-entity-id="{{ accompanyingCourse.id }}"></div>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_CREATE', accompanyingCourse) %}
<ul class="record_actions sticky-form-buttons">
<li class="create">
<a href="{{ path('accompanying_course_document_new', {'course': accompanyingCourse.id}) }}" class="btn btn-create">
{{ 'Create'|trans }}
</a>
</li>
</ul>
{% endif %}
</div>
{% endblock %}

View File

@ -44,12 +44,14 @@
<div class="col-md-10 col-xxl"> <div class="col-md-10 col-xxl">
<h1>{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}</h1> <h1>{{ 'Documents for %name%'|trans({ '%name%': person|chill_entity_render_string } ) }}</h1>
{{ filter|chill_render_filter_order_helper }}
{% if documents|length == 0 %} {% if documents|length == 0 %}
<p class="chill-no-data-statement">{{ 'No documents'|trans }}</p> <p class="chill-no-data-statement">{{ 'No documents'|trans }}</p>
{% else %} {% else %}
<div class="flex-table chill-task-list"> <div class="flex-table chill-task-list">
{% for document in documents %} {% for document in documents %}
{% include 'ChillDocStoreBundle:List:list_item.html.twig' %} {{ document|chill_generic_doc_render }}
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@ -10,8 +10,23 @@
{% elseif document.object.isFailure %} {% elseif document.object.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div> <div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %} {% endif %}
{% if context == 'person' and accompanyingCourse is defined %}
<div>
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ accompanyingCourse.id }}
</span>&nbsp;
</div>
{% elseif context == 'accompanying-period' and person is defined %}
<div>
<span class="badge bg-primary">
{{ 'Document from person %name%'|trans({ '%name%': document.person|chill_entity_render_string }) }}
</span>&nbsp;
</div>
{% endif %}
<div class="denomination h2"> <div class="denomination h2">
{{ document.title }} {{ document.title|chill_print_or_message("No title") }}
</div> </div>
{% if document.object.type is not empty %} {% if document.object.type is not empty %}
<div> <div>

View File

@ -36,8 +36,8 @@
'title' : 'Delete document ?'|trans, 'title' : 'Delete document ?'|trans,
'display_content' : block('docdescription'), 'display_content' : block('docdescription'),
'confirm_question' : 'Are you sure you want to remove this document ?'|trans, 'confirm_question' : 'Are you sure you want to remove this document ?'|trans,
'cancel_route' : 'person_document_index', 'cancel_route' : 'chill_docstore_generic-doc_by-person_index',
'cancel_parameters' : {'person' : person.id, 'id': document.id}, 'cancel_parameters' : {'id' : person.id},
'form' : delete_form 'form' : delete_form
} ) }} } ) }}
{% endblock %} {% endblock %}

View File

@ -38,7 +38,7 @@
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li class="cancel"> <li class="cancel">
<a href="{{ chill_return_path_or('person_document_index', {'person': person.id}) }}" class="btn btn-cancel"> <a href="{{ chill_return_path_or('chill_docstore_generic-doc_by-person_index', {'id': person.id}) }}" class="btn btn-cancel">
{{ 'Back to the list' | trans }} {{ 'Back to the list' | trans }}
</a> </a>
</li> </li>

View File

@ -42,7 +42,7 @@
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li class="cancel"> <li class="cancel">
<a href="{{ chill_return_path_or('person_document_index', {'person': person.id}) }}" class="btn btn-cancel"> <a href="{{ chill_return_path_or('chill_docstore_generic-doc_by-person_index', {'id': person.id}) }}" class="btn btn-cancel">
{{ 'Back to the list' | trans }} {{ 'Back to the list' | trans }}
</a> </a>
</li> </li>

View File

@ -63,7 +63,7 @@
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li class="cancel"> <li class="cancel">
<a href="{{ path('person_document_index', {'person': person.id}) }}" class="btn btn-cancel"> <a href="{{ chill_return_path_or('chill_docstore_generic-doc_by-person_index', {'id': person.id}) }}" class="btn btn-cancel">
{{ 'Back to the list' | trans }} {{ 'Back to the list' | trans }}
</a> </a>
</li> </li>

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\GenericDoc;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
class FetchQueryToSqlBuilderTest extends KernelTestCase
{
public function testToSql(): void
{
$query = new FetchQuery(
'test',
'jsonb_build_object(\'id\', a.column)',
'a.datecolumn',
'my_table a'
);
$query->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);
}
}

View File

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\GenericDoc;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\DocStoreBundle\GenericDoc\Manager;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
* @coversNothing
*/
class ManagerTest extends KernelTestCase
{
use ProphecyTrait;
private EntityManagerInterface $em;
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
$this->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;
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\GenericDoc\Providers;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\GenericDoc\Providers\AccompanyingCourseDocumentGenericDocProvider;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class AccompanyingCourseDocumentGenericDocProviderTest extends KernelTestCase
{
use ProphecyTrait;
private EntityManagerInterface $entityManager;
public function setUp(): void
{
self::bootKernel();
$this->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'];
}
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\GenericDoc\Providers;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\GenericDoc\Providers\PersonDocumentGenericDocProvider;
use Chill\DocStoreBundle\Repository\PersonDocumentACLAwareRepositoryInterface;
use Chill\PersonBundle\Entity\Person;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class PersonDocumentGenericDocProviderTest extends KernelTestCase
{
use ProphecyTrait;
private EntityManagerInterface $entityManager;
private PersonDocumentACLAwareRepositoryInterface $personDocumentACLAwareRepository;
protected function setUp(): void
{
self::bootKernel();
$this->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'];
}
}

View File

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Repository;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\DocStoreBundle\Repository\PersonDocumentACLAwareRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Repository\ScopeRepositoryInterface;
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\Person;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class PersonDocumentACLAwareRepositoryTest extends KernelTestCase
{
use ProphecyTrait;
private EntityManagerInterface $entityManager;
private ScopeRepositoryInterface $scopeRepository;
protected function setUp(): void
{
self::bootKernel();
$this->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'];
}
}

View File

@ -45,3 +45,29 @@ services:
autoconfigure: true autoconfigure: true
resource: '../Service/' 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/'

View File

@ -18,11 +18,19 @@ No document found: Aucun document trouvé
The document is successfully registered: Le document est enregistré The document is successfully registered: Le document est enregistré
The document is successfully updated: Le document est mis à jour The document is successfully updated: Le document est mis à jour
Any description: Aucune description Any description: Aucune description
Document from person %name%: Document de l'usager %name%
See the document: Voir le document See the document: Voir le document
document: document:
Any title: Aucun titre 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
Delete document ?: Supprimer le document ? Delete document ?: Supprimer le document ?
Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ? Are you sure you want to remove this document ?: Êtes-vous sûr·e de vouloir supprimer ce document ?

View File

@ -91,6 +91,11 @@ class FilterOrderHelper
return $this->checkboxes; return $this->checkboxes;
} }
public function hasCheckBox(string $name): bool
{
return array_key_exists($name, $this->checkboxes);
}
/** /**
* @return array<'to': DateTimeImmutable, 'from': DateTimeImmutable> * @return array<'to': DateTimeImmutable, 'from': DateTimeImmutable>
*/ */

View File

@ -42,6 +42,7 @@ by_user: "par "
lifecycleUpdate: Evenements de création et mise à jour lifecycleUpdate: Evenements de création et mise à jour
address_fields: Données liées à l'adresse address_fields: Données liées à l'adresse
Datas: Données Datas: Données
No title: Aucun titre
inactive: inactif inactive: inactif

View File

@ -271,6 +271,7 @@ class AccompanyingPeriod implements
* cascade={"persist", "refresh", "remove", "merge", "detach"}) * cascade={"persist", "refresh", "remove", "merge", "detach"})
* @Groups({"read", "docgen:read"}) * @Groups({"read", "docgen:read"})
* @ParticipationOverlap(groups={AccompanyingPeriod::STEP_DRAFT, AccompanyingPeriod::STEP_CONFIRMED}) * @ParticipationOverlap(groups={AccompanyingPeriod::STEP_DRAFT, AccompanyingPeriod::STEP_CONFIRMED})
* @var Collection<AccompanyingPeriodParticipation>
*/ */
private Collection $participations; private Collection $participations;
@ -873,6 +874,7 @@ class AccompanyingPeriod implements
/** /**
* Get Participations Collection. * Get Participations Collection.
* @return Collection<AccompanyingPeriodParticipation>
*/ */
public function getParticipations(): Collection public function getParticipations(): Collection
{ {

View File

@ -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 /// AccompanyingCourse Work Pages
div.accompanying-course-work { div.accompanying-course-work {

View File

@ -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 %}
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.storedObject.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.storedObject.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.storedObject.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div>
{% if context == 'person' %}
<span class="badge bg-primary">
<i class="fa fa-random"></i> {{ w.accompanyingPeriod.id }}
</span>&nbsp;
{% endif %}
<div class="badge-accompanying-work-type">
<span class="title_label"></span>
<span class="title_action">{{ w.socialAction|chill_entity_render_string }} > {{ document.accompanyingPeriodWorkEvaluation.evaluation.title|localize_translatable_string }}</span>
</div>
</div>
<div class="denomination h2">
{{ document.title|chill_print_or_message("No title") }}
</div>
{% if document.storedObject.type is not empty %}
<div>
{{ mm.mimeIcon(document.storedObject.type) }}
</div>
{% endif %}
{% if document.storedObject.hasTemplate %}
<div>
<p>{{ document.storedObject.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>
<div class="item-col">
<div class="container">
<div class="dates row text-end">
<span>{{ document.storedObject.createdAt|format_date('short') }}</span>
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div class="item-col item-meta">
{{ mmm.createdBy(document) }}
</div>
<ul class="item-col record_actions flex-shrink-1">
<li>
{{ chill_entity_workflow_list('Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument', document.id) }}
</li>
{% if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_DOCUMENT_SHOW', document) %}
<li>
{{ document.storedObject|chill_document_button_group(document.title, is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', document.accompanyingPeriodWorkEvaluation.accompanyingPeriodWork)) }}
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_SEE', w)%}
<li>
<a href="{{ chill_path_add_return_path('chill_person_accompanying_period_work_show', {'id': w.id, 'docId': document.id}) }}" class="btn btn-show"></a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE', w) %}
<li>
<a href="{{ chill_path_add_return_path('chill_person_accompanying_period_work_edit', {'id': w.id, 'docId': document.id}) }}" class="btn btn-edit"></a>
</li>
{% endif %}
</ul>
</div>
</div>

View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Service\GenericDoc\Providers;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\GenericDoc\FetchQuery;
use Chill\DocStoreBundle\GenericDoc\FetchQueryInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForAccompanyingPeriodProviderInterface;
use Chill\DocStoreBundle\GenericDoc\GenericDocForPersonProviderInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodWorkVoter;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Security;
final readonly class AccompanyingPeriodWorkEvaluationGenericDocProvider implements GenericDocForAccompanyingPeriodProviderInterface, GenericDocForPersonProviderInterface
{
public const KEY = 'accompanying_period_work_evaluation_document';
public function __construct(
private Security $security,
private EntityManagerInterface $entityManager,
) {
}
public function buildFetchQueryForAccompanyingPeriod(AccompanyingPeriod $accompanyingPeriod, ?\DateTimeImmutable $startDate = null, ?\DateTimeImmutable $endDate = null, ?string $content = null, ?string $origin = null): FetchQueryInterface
{
$accompanyingPeriodWorkMetadata = $this->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;
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Service\GenericDoc\Renderer;
use Chill\DocStoreBundle\GenericDoc\GenericDocDTO;
use Chill\DocStoreBundle\GenericDoc\Twig\GenericDocRendererInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocumentRepository;
use Chill\PersonBundle\Service\GenericDoc\Providers\AccompanyingPeriodWorkEvaluationGenericDocProvider;
final readonly class AccompanyingPeriodWorkEvaluationGenericDocRenderer implements GenericDocRendererInterface
{
public function __construct(
private AccompanyingPeriodWorkEvaluationDocumentRepository $accompanyingPeriodWorkEvaluationDocumentRepository,
) {
}
public function supports(GenericDocDTO $genericDocDTO, $options = []): bool
{
return $genericDocDTO->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(),
];
}
}

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Service\GenericDoc\Providers;
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
use Chill\CalendarBundle\Service\GenericDoc\Providers\AccompanyingPeriodCalendarGenericDocProvider;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class AccompanyingPeriodCalendarGenericDocProviderTest extends KernelTestCase
{
use ProphecyTrait;
private Security $security;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$this->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];
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Service\GenericDoc\Providers;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Service\GenericDoc\Providers\AccompanyingPeriodWorkEvaluationGenericDocProvider;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class AccompanyingPeriodWorkEvaluationGenericDocProviderTest extends KernelTestCase
{
use ProphecyTrait;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$this->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'];
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Service\GenericDoc\Providers;
use Chill\CalendarBundle\Service\GenericDoc\Providers\PersonCalendarGenericDocProvider;
use Chill\DocStoreBundle\GenericDoc\FetchQueryToSqlBuilder;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @internal
* @coversNothing
*/
class PersonCalendarGenericDocProviderTest extends KernelTestCase
{
private Security $security;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$this->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];
}
}

View File

@ -1236,3 +1236,8 @@ social_action:
social_issue: social_issue:
and children: et dérivés and children: et dérivés
generic_doc:
filter:
keys:
accompanying_period_work_evaluation_document: Document des actions d'accompagnement