Compare commits

..

29 Commits

Author SHA1 Message Date
4028c020ee Release v4.12.0 2026-01-15 18:02:12 +01:00
0d4eef6a0c Merge branch '493-fix-stored-object-workflow-permission' into 'master'
Fix issues with permission for stored objects associated with workflows

Closes #493

See merge request Chill-Projet/chill-bundles!951
2026-01-15 16:54:37 +00:00
b6152d5356 Fix issues with permission for stored objects associated with workflows 2026-01-15 16:54:37 +00:00
8b708f8c73 fix CommentInput: replace deprecated value binding with model-value 2026-01-15 14:53:40 +01:00
8d5b200107 Restrict ux-translator version to 2.31.0 2026-01-15 14:44:05 +01:00
a9e9207d5a Update php-cs-fixer version 2026-01-15 13:41:00 +01:00
3915574ed4 phpstan error fix 2026-01-15 13:40:46 +01:00
f3217d22ef Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center 2026-01-15 13:25:54 +01:00
06c5affbe7 Increase delay for removing stale workflows from 90 to 180 days
- Updated `KEEP_INTERVAL` in `CancelStaleWorkflowCronJob` to `P180D`.
2026-01-15 10:08:40 +01:00
bf461a1211 Merge branch '473-display-bundles-version' into 'master'
Resolve "Afficher le numéro de version de Chill dans l'UX"

Closes #473

See merge request Chill-Projet/chill-bundles!947
2026-01-13 15:35:26 +00:00
3f0ad51114 Resolve "Afficher le numéro de version de Chill dans l'UX" 2026-01-13 15:35:26 +00:00
a4de8eaab3 Merge branch '489-fix-desactivation-date-goarls-results' into 'master'
Fix issue with goal/result deactivation date handling and improve formatting

Closes #489

See merge request Chill-Projet/chill-bundles!949
2026-01-13 15:32:08 +00:00
2feb137ac2 Fix issue with goal/result deactivation date handling and improve formatting 2026-01-13 15:32:07 +00:00
5ea74d118b Merge branch '490-fix-double-notification' into 'master'
Prevent notifications from being sent when the user signs a document he asked to himself

Closes #490

See merge request Chill-Projet/chill-bundles!950
2026-01-13 15:31:50 +00:00
8eb7a55ef5 Prevent notifications from being sent when the user signs a document he asked to himself 2026-01-13 15:31:49 +00:00
281887355f Fix calculation of budget balance 2026-01-12 10:34:30 +01:00
47b285b584 Fix export group by center for persons without a center in CenterAggregator.php 2025-12-30 13:01:56 +01:00
7c9b4d02f6 Fix ordering of social actions
Actions with a closing date in the future should be considered as 'still open'.
2025-12-18 11:08:18 +01:00
3ff9bba4de Fix the condition to display concerned persons in calendar list items. 2025-12-18 10:24:24 +01:00
c0f9e953fb Update to v4.11.0 2025-12-17 16:56:35 +01:00
a49ea2b6b9 Fix translation syntax
Cannot start with %, wrap translation value in double quotes
2025-12-17 16:54:33 +01:00
a30232d3ce Merge branch '478-admin-list-filters' into 'master'
Resolve "Add filters to admin lists"

Closes #478

See merge request Chill-Projet/chill-bundles!941
2025-12-15 16:49:39 +00:00
aae55e6f8c Merge branch '466-fix-migrations' into 'master'
Fix migration to exclude null `user_id` in `activity_user` population

Closes #466

See merge request Chill-Projet/chill-bundles!943
2025-12-15 13:43:20 +00:00
c9513f2f6c Fix migration to exclude null user_id in activity_user population 2025-12-15 13:43:20 +00:00
11d7425883 php cs fixes 2025-12-15 10:48:20 +01:00
08897e0981 Fix count of total items for correct paginator display 2025-12-15 10:48:00 +01:00
98cbfed054 Add filtering methods to controllers 2025-12-15 10:48:00 +01:00
9af4d19744 Add repository methods for filtering 2025-12-15 10:48:00 +01:00
c1cf5a8bb2 Start implementation of filter within admin index pages 2025-12-15 10:48:00 +01:00
51 changed files with 1490 additions and 449 deletions

9
.changes/v4.11.0.md Normal file
View File

@@ -0,0 +1,9 @@
## v4.11.0 - 2025-12-17
### Feature
* ([#478](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/478)) Add filtering to admin lists: social actions, social issues, goals, results, and evaluations
### Fixed
* ([#466](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/466)) Fix migration query after previous fix
* Fix translation key/value
Cannot start with % and should be wrapped in "".

16
.changes/v4.12.0.md Normal file
View File

@@ -0,0 +1,16 @@
## v4.12.0 - 2026-01-15
### Feature
* ([#473](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/473)) Display version of chill bundles in application footer
* Increase the delay before removing stale workflow from 90 days to 180 days.
### Fixed
* ([#480](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/480)) Fix the condition to display concerned persons in calendar list items.
* ([#481](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/481)) Fix ordering of social actions: actions with a closing date in the future should be considered as 'still open'.
* ([#477](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/477)) Fix export group by center for persons without a center in CenterAggregator.php
* Fix the calculation of budget balance to only take into account resources and charges that are still actual
* ([#489](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/489)) Fix desactivation date for Goals and results
* ([#490](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/490)) Prevent sending a notification when the user signs the document himself
* ([#491](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/491)) Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center
* ([#492](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/492)) fix CommentInput: replace deprecated value binding with model-value
* ([#493](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/493)) fix issue with stored object permissions associated with workflows (as attachment, or through a related entity)
BC: the constructor's signature of `\Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter` has changed.

View File

@@ -6,6 +6,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.12.0 - 2026-01-15
### Feature
* ([#473](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/473)) Display version of chill bundles in application footer
* Increase the delay before removing stale workflow from 90 days to 180 days.
### Fixed
* ([#480](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/480)) Fix the condition to display concerned persons in calendar list items.
* ([#481](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/481)) Fix ordering of social actions: actions with a closing date in the future should be considered as 'still open'.
* ([#477](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/477)) Fix export group by center for persons without a center in CenterAggregator.php
* Fix the calculation of budget balance to only take into account resources and charges that are still actual
* ([#489](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/489)) Fix desactivation date for Goals and results
* ([#490](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/490)) Prevent sending a notification when the user signs the document himself
* ([#491](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/491)) Fix: acc periods of which user is the referrer should not be included if when the list is filtered by center and none of the participations are part of the center
* ([#492](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/492)) fix CommentInput: replace deprecated value binding with model-value
* ([#493](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/493)) fix issue with stored object permissions associated with workflows (as attachment, or through a related entity)
## v4.11.0 - 2025-12-17
### Feature
* ([#478](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/478)) Add filtering to admin lists: social actions, social issues, goals, results, and evaluations
### Fixed
* ([#466](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/466)) Fix migration query after previous fix
* Fix translation key/value
Cannot start with % and should be wrapped in "".
## v4.10.1 - 2025-12-11
### Fixed
* Fix missing translation variable in NewLocation component

View File

@@ -21,6 +21,7 @@
"ext-openssl": "*",
"ext-redis": "*",
"ext-zlib": "*",
"composer-runtime-api": "*",
"champs-libres/wopi-bundle": "dev-symfony-v5@dev",
"champs-libres/wopi-lib": "dev-master@dev",
"doctrine/data-fixtures": "^1.8",
@@ -82,7 +83,7 @@
"symfony/templating": "^5.4",
"symfony/translation": "^5.4",
"symfony/twig-bundle": "^5.4",
"symfony/ux-translator": "^2.22",
"symfony/ux-translator": "2.31.0",
"symfony/validator": "^5.4",
"symfony/webpack-encore-bundle": "^1.11",
"symfony/workflow": "^5.4",
@@ -97,7 +98,7 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3",
"fakerphp/faker": "^1.13",
"friendsofphp/php-cs-fixer": "3.65.0",
"friendsofphp/php-cs-fixer": "3.92.5",
"jangregor/phpstan-prophecy": "^1.0",
"nelmio/alice": "^3.8",
"nikic/php-parser": "^4.15",

View File

@@ -16,7 +16,8 @@ use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelperInterface;
use Symfony\Component\Security\Core\Security;
class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
@@ -24,9 +25,10 @@ class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
public function __construct(
private readonly ActivityRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -39,7 +39,7 @@ final class Version20251118124241 extends AbstractMigration
$this->addSql("COMMENT ON COLUMN activity_user.by_migration IS 'For backup purpose - can be safely deleted after a while. See migration \\Chill\\Migrations\\Activity\\Version20251118124241'");
$this->addSql('INSERT INTO activity_user (activity_id, user_id, by_migration)
SELECT id, user_id, true FROM activity
SELECT id, user_id, true FROM activity WHERE user_id is not null
ON CONFLICT DO NOTHING');
}

View File

@@ -72,14 +72,20 @@
{% macro table_results(actualCharges, actualResources, results) %}
{% set now = date() %}
{% set totalCharges = 0 %}
{% for c in actualCharges %}
{% set totalCharges = totalCharges + c.amount %}
{% if c.startDate <= now and (c.endDate is null or c.endDate >= now) %}
{% set totalCharges = totalCharges + c.amount %}
{% endif %}
{% endfor %}
{% set totalResources = 0 %}
{% for r in actualResources %}
{% set totalResources = totalResources + r.amount %}
{% if r.startDate <= now and (r.endDate is null or r.endDate >= now) %}
{% set totalResources = totalResources + r.amount %}
{% endif %}
{% endfor %}
{% set result = (totalResources - totalCharges) %}

View File

@@ -75,7 +75,7 @@
</div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0
or calendar.persons|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
<div class="item-row details separator">

View File

@@ -15,7 +15,10 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelperInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
@@ -34,7 +37,8 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function __construct(
private readonly Security $security,
private readonly ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
private readonly WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService,
) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
@@ -46,16 +50,6 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{
// we first try to get the permission from the workflow, as attachement (this is the less intensive query)
$workflowPermissionAsAttachment = match ($attribute) {
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject),
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject),
};
if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) {
return false;
}
// Retrieve the related entity
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
@@ -65,7 +59,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
$regularPermission = $this->security->isGranted($voterAttribute, $entity);
if (!$this->canBeAssociatedWithWorkflow()) {
return $regularPermission;
return $this->voteOnStoredObjectAsAttachementOfAWorkflow($attribute, $regularPermission, $subject);
}
$workflowPermission = match ($attribute) {
@@ -74,9 +68,41 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
};
return match ($workflowPermission) {
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN => $this->voteOnStoredObjectAsAttachementOfAWorkflow($attribute, $regularPermission, $subject),
};
}
private function voteOnStoredObjectAsAttachementOfAWorkflow(StoredObjectRoleEnum $attribute, bool $regularPermission, StoredObject $storedObject): bool
{
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($storedObject);
// we get all the entity workflows where the stored object is attached
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
// we compute all the permission for each entity workflow
$permissions = array_map(fn (EntityWorkflow $entityWorkflow): string => match ($attribute) {
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($entityWorkflow),
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($entityWorkflow),
}, $entityWorkflows);
// now, we reduce the permissions: abstain are ignored. Between DENIED and and GRANT, DENIED takes precedence
$computedPermission = WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN;
foreach ($permissions as $permission) {
if (WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED === $permission) {
return false;
}
if (WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT === $permission) {
$computedPermission = WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT;
}
}
if (WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN === $computedPermission) {
return $regularPermission;
}
// this is the case where WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT is returned
return true;
}
}

View File

@@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Symfony\Component\Security\Core\Security;
@@ -25,8 +26,9 @@ final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredOb
private readonly AccompanyingCourseDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Symfony\Component\Security\Core\Security;
@@ -25,8 +26,9 @@ class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
private readonly PersonDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -16,8 +16,11 @@ use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelperInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Security;
@@ -31,21 +34,31 @@ class AbstractStoredObjectVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @param array<int, EntityWorkflowAttachment> $attachments
*
* @return void
*/
private function buildStoredObjectVoter(
bool $canBeAssociatedWithWorkflow,
AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
?WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService = null,
array $attachments = [],
): AbstractStoredObjectVoter {
$attachmentsRepository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachmentsRepository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
// Anonymous class extending the abstract class
return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter {
return new class ($canBeAssociatedWithWorkflow, $repository, $security, $attachmentsRepository->reveal(), $workflowDocumentService) extends AbstractStoredObjectVoter {
public function __construct(
private readonly bool $canBeAssociatedWithWorkflow,
private readonly AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null,
EntityWorkflowAttachmentRepository $attachmentRepository,
WorkflowRelatedEntityPermissionHelperInterface $workflowDocumentService,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function attributeToRole($attribute): string
@@ -72,28 +85,29 @@ class AbstractStoredObjectVoterTest extends TestCase
public function testSupportsOnAttribute(): void
{
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), null);
$entityWorkflowService = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), null);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), null);
$voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), $entityWorkflowService->reveal());
self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject()));
}
/**
* @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission
* @dataProvider dataProviderVoteOnAttributeWithWorkflow
*/
public function testVoteOnAttributeWithStoredObjectPermission(
public function testVoteOnAttributeWithWorkflow(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $isGrantedRegularPermission,
string $isGrantedWorkflowPermission,
string $isGrantedStoredObjectAttachment,
): void {
$storedObject = new StoredObject();
$repository = new DummyRepository($related = new \stdClass());
@@ -102,31 +116,28 @@ class AbstractStoredObjectVoterTest extends TestCase
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$attachementRepository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachementRepository->findByStoredObject($storedObject)->willReturn([]);
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)
->shouldBeCalled()
->willReturn($isGrantedStoredObjectAttachment);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermission);
} elseif (StoredObjectRoleEnum::EDIT === $attribute) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)
->shouldBeCalled()
->willReturn($isGrantedStoredObjectAttachment);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)
->willReturn($isGrantedWorkflowPermission);
} else {
throw new \LogicException('Invalid attribute for StoredObjectVoter');
}
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal()) extends AbstractStoredObjectVoter {
public function __construct(private $repository, $helper, $security)
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal(), $attachementRepository->reveal()) extends AbstractStoredObjectVoter {
public function __construct(private $repository, $helper, $security, EntityWorkflowAttachmentRepository $attachmentRepository)
{
parent::__construct($security, $helper);
parent::__construct($security, $attachmentRepository, $helper);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
@@ -155,96 +166,64 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $actual);
}
public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable
public static function dataProviderVoteOnAttributeWithWorkflow(): iterable
{
foreach (['read' => StoredObjectRoleEnum::SEE, 'write' => StoredObjectRoleEnum::EDIT] as $action => $attribute) {
yield 'Not related to any workflow nor attachment ('.$action.')' => [
$attribute,
true,
true,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (stored object) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (workflow) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
true,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
];
yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (initially refused) (stored object) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Force grant inverse the regular permission (workflow) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
];
yield 'Force grant inverse the regular permission (so) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
}
}
/**
* @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission
* @dataProvider dataProviderVoteOnAttribute
*/
public function testVoteOnAttributeWithoutStoredObjectPermission(
public function testVoteOnAttribute(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $canBeAssociatedWithWorkflow,
@@ -260,10 +239,7 @@ class AbstractStoredObjectVoterTest extends TestCase
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
if (null !== $isGrantedWorkflowPermissionRead) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
@@ -283,27 +259,155 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
}
public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable
public static function dataProviderVoteOnAttribute(): iterable
{
// not associated on a workflow
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];
yield [StoredObjectRoleEnum::SEE, false, false, false, null, null, 'not associated on a workflow, denied by regular access, must not rely on helper'];
// associated on a workflow, read operation
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied'];
yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted'];
yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied'];
// association on a workflow, write operation
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied'];
yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted'];
yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied'];
}
/**
* @dataProvider dataProviderPrecedenceOfDirectAssociationOverWorkflowAttachments
*/
public function testPrecedenceOfDirectAssociationOverWorkflowAttachments(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $regularPermission,
string $directWorkflowPermission,
string $attachmentWorkflowPermission,
string $message,
): void {
$storedObject = new StoredObject();
$repository = new DummyRepository($related = new \stdClass());
$token = new UsernamePasswordToken(new User(), 'dummy');
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($regularPermission);
$workflowHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelperInterface::class);
// Direct association permission
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($directWorkflowPermission);
} else {
$workflowHelper->isAllowedByWorkflowForWriteOperation($related)
->willReturn($directWorkflowPermission);
}
// Attachment permission
$entityWorkflow = $this->prophesize(\Chill\MainBundle\Entity\Workflow\EntityWorkflow::class)->reveal();
$attachment = $this->prophesize(EntityWorkflowAttachment::class);
$attachment->getEntityWorkflow()->willReturn($entityWorkflow);
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowHelper->isAllowedByWorkflowForReadOperation($entityWorkflow)
->willReturn($attachmentWorkflowPermission);
} else {
$workflowHelper->isAllowedByWorkflowForWriteOperation($entityWorkflow)
->willReturn($attachmentWorkflowPermission);
}
$voter = $this->buildStoredObjectVoter(
true,
$repository,
$security->reveal(),
$workflowHelper->reveal(),
[$attachment->reveal()]
);
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
}
public static function dataProviderPrecedenceOfDirectAssociationOverWorkflowAttachments(): iterable
{
$cases = [
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'message' => 'Direct FORCE_GRANT should win over attachment FORCE_DENIED',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'message' => 'Direct FORCE_DENIED should win over attachment FORCE_GRANT',
],
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Direct FORCE_GRANT should win over attachment ABSTAIN',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Direct FORCE_DENIED should win over attachment ABSTAIN',
],
[
'expected' => true,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_GRANT,
'message' => 'Direct ABSTAIN should let attachment FORCE_GRANT win',
],
[
'expected' => false,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::FORCE_DENIED,
'message' => 'Direct ABSTAIN should let attachment FORCE_DENIED win',
],
[
'expected' => true,
'regular' => true,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Both ABSTAIN should let regular permission (true) win',
],
[
'expected' => false,
'regular' => false,
'direct' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'attachment' => WorkflowRelatedEntityPermissionHelperInterface::ABSTAIN,
'message' => 'Both ABSTAIN should let regular permission (false) win',
],
];
foreach ([StoredObjectRoleEnum::SEE, StoredObjectRoleEnum::EDIT] as $attribute) {
foreach ($cases as $case) {
yield sprintf('%s - %s', $attribute->name, $case['message']) => [
$attribute,
$case['expected'],
$case['regular'],
$case['direct'],
$case['attachment'],
$case['message'],
];
}
}
}
}

View File

@@ -14,6 +14,7 @@ namespace Chill\EventBundle\Security\Authorization;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Repository\EventRepository;
@@ -26,8 +27,9 @@ class EventStoredObjectVoter extends AbstractStoredObjectVoter
private readonly EventRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -189,14 +189,14 @@ crud:
title_edit: Rapport "belemmering" bewerken
title_delete: Belemmering verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
cscv:
title_new: Nieuw CV voor %person%
title_view: CV voor %person%
title_edit: CV bewerken
title_delete: CV verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
no_date: Geen datum aangegeven
no_end_date: einddatum onbekend
no_start_date: startdatum onbekend
@@ -206,7 +206,7 @@ crud:
title_edit: Immersie bewerken
title_delete: Immersie verwijderen
button_delete: Verwijderen
confirm_message_delete: %as_string% verwijderen?
confirm_message_delete: "%as_string% verwijderen?"
projet_prof:
title_new: Nieuw professioneel project voor %person%
title_view: Professioneel project voor %person%

View File

@@ -2,6 +2,10 @@
<p>
{{ 'This program is free software: you can redistribute it and/or modify it under the terms of the <strong>GNU Affero General Public License</strong>'|trans|raw }}
<br/>
{% if get_chill_version() %}
{{ 'footer.Running chill version %version%'|trans({ '%version%': get_chill_version() }) }}
{% endif %}
<br/>
<a name="bottom" class="btn text-white" href="https://gitea.champs-libres.be/Chill-project/manuals/releases" target="_blank">
{{ 'User manual'|trans }}
</a>

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service;
use Composer\InstalledVersions;
readonly class VersionProvider
{
public function __construct(private string $packageName) {}
public function getVersion(): string
{
try {
$version = InstalledVersions::getPrettyVersion($this->packageName);
if (null === $version) {
return 'unknown';
}
return $version;
} catch (\OutOfBoundsException) {
return 'unknown';
}
}
public function getFormattedVersion(): string
{
$version = $this->getVersion();
if ('unknown' === $version) {
return 'Version unavailable';
}
return $version;
}
}

View File

@@ -23,7 +23,7 @@ class CancelStaleWorkflowCronJob implements CronJobInterface
{
public const KEY = 'remove-stale-workflow';
public const KEEP_INTERVAL = 'P90D';
public const KEEP_INTERVAL = 'P180D';
private const LAST_CANCELED_WORKFLOW = 'last-canceled-workflow-id';

View File

@@ -0,0 +1,35 @@
<?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\MainBundle\Templating;
use Chill\MainBundle\Service\VersionProvider;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class VersionRenderExtension extends AbstractExtension
{
public function __construct(
private readonly VersionProvider $versionProvider,
) {}
public function getFunctions(): array
{
return [
new TwigFunction('get_chill_version', $this->getChillVersion(...)),
];
}
public function getChillVersion(): string
{
return $this->versionProvider->getFormattedVersion();
}
}

View File

@@ -42,14 +42,14 @@ class CancelStaleWorkflowHandlerTest extends TestCase
{
use ProphecyTrait;
public function testWorkflowWithOneStepOlderThan90DaysIsCanceled(): void
public function testWorkflowWithOneStepOlderThan180DaysIsCanceled(): void
{
$clock = new MockClock('2024-01-01');
$daysAgos = new \DateTimeImmutable('2023-09-01');
$daysAgos = new \DateTimeImmutable('2023-06-01');
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01'));
$workflow->setCreatedAt(new \DateTimeImmutable('2023-06-01'));
$workflow->setStep('step1', new WorkflowTransitionContextDTO($workflow), 'to_step1', $daysAgos, new User());
$em = $this->prophesize(EntityManagerInterface::class);
@@ -94,7 +94,7 @@ class CancelStaleWorkflowHandlerTest extends TestCase
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01'));
$workflow->setCreatedAt(new \DateTimeImmutable('2023-06-01'));
$em = $this->prophesize(EntityManagerInterface::class);
$em->flush()->shouldBeCalled();

View File

@@ -15,7 +15,9 @@ use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\EventSubscriber\NotificationOnTransition;
@@ -87,7 +89,7 @@ final class NotificationOnTransitionTest extends TestCase
->willReturn([]);
$registry = $this->prophesize(Registry::class);
$registry->get(Argument::type(EntityWorkflow::class), Argument::type('string'))
$registry->get(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn($workflow);
$security = $this->prophesize(Security::class);
@@ -111,4 +113,74 @@ final class NotificationOnTransitionTest extends TestCase
$notificationOnTransition->onCompletedSendNotification($event);
}
public function testOnCompleteDoNotSendNotificationIfStepCreatedByPreviousSignature(): void
{
$dest = new User();
$currentUser = new User();
$workflowProphecy = $this->prophesize(WorkflowInterface::class);
$workflow = $workflowProphecy->reveal();
$entityWorkflow = new EntityWorkflow();
$entityWorkflow
->setWorkflowName('workflow_name')
->setRelatedEntityClass(\stdClass::class)
->setRelatedEntityId(1);
// force an id to entityWorkflow:
$reflection = new \ReflectionClass($entityWorkflow);
$id = $reflection->getProperty('id');
$id->setValue($entityWorkflow, 1);
$previousStep = new EntityWorkflowStep();
$previousStep->addSignature($signature = new EntityWorkflowStepSignature($previousStep, $dest));
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
$currentStep = new EntityWorkflowStep();
$currentStep->addDestUser($dest);
$currentStep->setCurrentStep('to_state');
$entityWorkflow->addStep($previousStep);
$entityWorkflow->addStep($currentStep);
$em = $this->prophesize(EntityManagerInterface::class);
// we check that NO notification has been persisted for $dest
$em->persist(Argument::that(
fn ($notificationCandidate) => $notificationCandidate instanceof Notification && $notificationCandidate->getAddressees()->contains($dest)
))->shouldNotBeCalled();
$engine = $this->prophesize(\Twig\Environment::class);
$engine->render(Argument::type('string'), Argument::type('array'))
->willReturn('dummy text');
$extractor = $this->prophesize(MetadataExtractor::class);
$extractor->buildArrayPresentationForPlace(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn([]);
$extractor->buildArrayPresentationForWorkflow(Argument::any())
->willReturn([]);
$registry = $this->prophesize(Registry::class);
$registry->get(Argument::type(EntityWorkflow::class), Argument::any())
->willReturn($workflow);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn(null);
$entityWorkflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$entityWorkflowHandler->getEntityTitle($entityWorkflow)->willReturn('workflow title');
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->getHandler($entityWorkflow)->willReturn($entityWorkflowHandler->reveal());
$notificationOnTransition = new NotificationOnTransition(
$em->reveal(),
$engine->reveal(),
$extractor->reveal(),
$security->reveal(),
$registry->reveal(),
$entityWorkflowManager->reveal(),
);
$event = new Event($entityWorkflow, new Marking(), new Transition('dummy_transition', ['from_state'], ['to_state']), $workflow);
$notificationOnTransition->onCompletedSendNotification($event);
}
}

View File

@@ -11,9 +11,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
@@ -269,217 +266,7 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows);
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn([]);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
}
/**
* @dataProvider provideDataAllowedByWorkflowReadOperationByAttachment
*
* @param list<EntityWorkflow> $entityWorkflows
*/
public function testAllowedByWorkflowReadByAttachment(
array $entityWorkflows,
User $user,
string $expected,
?\DateTimeImmutable $atDate,
string $message,
): void {
// all entities must have this workflow name, so we are ok to set it here
foreach ($entityWorkflows as $entityWorkflow) {
$entityWorkflow->setWorkflowName('dummy');
}
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new StoredObject()), $message);
}
public static function provideDataAllowedByWorkflowReadOperationByAttachment(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user is a current user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'Abstain: there is a signature for person, but the attachment is not concerned'];
}
/**
* @dataProvider provideDataAllowedByWorkflowWriteOperationByAttachment
*
* @param list<EntityWorkflow> $entityWorkflows
*/
public function testAllowedByWorkflowWriteByAttachment(
array $entityWorkflows,
User $user,
string $expected,
?\DateTimeImmutable $atDate,
string $message,
): void {
// all entities must have this workflow name, so we are ok to set it here
foreach ($entityWorkflows as $entityWorkflow) {
$entityWorkflow->setWorkflowName('dummy');
}
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new StoredObject()), $message);
}
public static function provideDataAllowedByWorkflowWriteOperationByAttachment(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because there is no workflow'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user (and attachment)'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user is a current user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user was not a previous user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied: user was a previous user, but it is finalized positive'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: user was a previous user, it is finalized, but finalized negative'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature, but not on the attachment'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature, but the signature is not on the attachment'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature on a canceled workflow'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied: the workflow is sent to an external user'];
}
/**
* @param list<EntityWorkflow> $entityWorkflows
*/
private function buildHelperForAttachment(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->shouldNotBeCalled();
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachments = [];
foreach ($entityWorkflows as $entityWorkflow) {
$attachments[] = new EntityWorkflowAttachment('dummy', ['id' => 1], $entityWorkflow, new StoredObject());
}
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
}
private static function buildRegistry(): Registry

View File

@@ -103,7 +103,10 @@ class NotificationOnTransition implements EventSubscriberInterface
foreach ($dests as $subscriber) {
if (
// prevent to send a notification to the one who created the step
$this->security->getUser() === $subscriber
// prevent to send a notification if the user applyied a signature on the previous step
|| $this->isStepCreatedByPreviousSignature($entityWorkflow, $subscriber)
) {
continue;
}
@@ -131,4 +134,31 @@ class NotificationOnTransition implements EventSubscriberInterface
$this->entityManager->persist($notification);
}
}
/**
* Checks if the current step in the workflow was created by a previous signature of the specified user.
*
* This method retrieves the current step of the workflow and its preceding step. It iterates through
* the signatures of the preceding step to verify if the provided user is the signer of any of those
* signatures. Returns true if the user matches any signer; otherwise, returns false.
*
* @param EntityWorkflow $entityWorkflow the workflow entity containing the current step and its details
* @param User $user the user to check against the signatures of the previous step in the workflow
*
* @return bool true if the specified user created the step via a previous signature, false otherwise
*/
private function isStepCreatedByPreviousSignature(EntityWorkflow $entityWorkflow, User $user): bool
{
$step = $entityWorkflow->getCurrentStepChained();
$previous = $step->getPrevious();
foreach ($previous->getSignatures() as $signature) {
if ($signature->getSigner() === $user) {
return true;
}
}
return false;
}
}

View File

@@ -11,12 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security;
@@ -52,48 +49,28 @@ use Symfony\Component\Workflow\Registry;
* the workflow denys write operations;
* - if there is no case above and the user is involved in the workflow (is part of the current step, of a step before), the user is granted;
*/
class WorkflowRelatedEntityPermissionHelper
final readonly class WorkflowRelatedEntityPermissionHelper implements WorkflowRelatedEntityPermissionHelperInterface
{
public const FORCE_GRANT = 'FORCE_GRANT';
public const FORCE_DENIED = 'FORCE_DENIED';
public const ABSTAIN = 'ABSTAIN';
public function __construct(
private readonly Security $security,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
private readonly Registry $registry,
private readonly ClockInterface $clock,
private Security $security,
private EntityWorkflowManager $entityWorkflowManager,
private Registry $registry,
private ClockInterface $clock,
) {}
/**
* @param object $entity The entity may be an
* @param object $entity The entity may be an object that is a related entity of a workflow, or an EntityWorkflow itself
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForReadOperation(object $entity): string
{
if ($entity instanceof StoredObject) {
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
$isAttached = true;
} else {
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$isAttached = false;
}
if ([] === $entityWorkflows) {
return self::ABSTAIN;
}
$entityWorkflows = $entity instanceof EntityWorkflow ? [$entity] : $this->entityWorkflowManager->findByRelatedEntity($entity);
if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) {
return self::FORCE_GRANT;
}
if ($isAttached) {
return self::ABSTAIN;
}
// give a view permission if there is a Person signature pending, or in the 12 hours following
// the signature last state
foreach ($entityWorkflows as $workflow) {
@@ -117,24 +94,20 @@ class WorkflowRelatedEntityPermissionHelper
}
/**
* @param object $entity the entity may be an object which is the related entity of a workflow
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForWriteOperation(object $entity): string
{
if ($entity instanceof StoredObject) {
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
$isAttached = true;
} else {
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$isAttached = false;
}
$entityWorkflows = $entity instanceof EntityWorkflow ? [$entity] : $this->entityWorkflowManager->findByRelatedEntity($entity);
if ([] === $entityWorkflows) {
return self::ABSTAIN;
}
// if a workflow is finalized positive, anyone is allowed to edit the document anymore
// if a workflow is finalized positive or isSentExternal, no one is allowed to edit the document anymore
foreach ($entityWorkflows as $entityWorkflow) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
@@ -147,39 +120,64 @@ class WorkflowRelatedEntityPermissionHelper
// the workflow is final, and final positive, or is sentExternal, so we stop here.
return self::FORCE_DENIED;
}
if (
// if not finalized positive
$entityWorkflow->isFinal() && !($placeMetadata['isFinalPositive'] ?? false)
) {
return self::ABSTAIN;
}
}
}
$runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal());
// if there is a signature on a **running workflow**, no one is allowed edit anymore, except if the workflow is canceled
foreach ($entityWorkflows as $entityWorkflow) {
// if the workflow is canceled, we ignore it
$isFinalNegative = false;
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if (isset($placeMetadata['isFinalPositive']) && false === $placeMetadata['isFinalPositive']) {
$isFinalNegative = true;
}
}
}
if ($isFinalNegative) {
continue;
}
// if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore
if (!$isAttached) {
foreach ($runningWorkflows as $entityWorkflow) {
foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return self::FORCE_DENIED;
}
foreach ($entityWorkflow->getSteps() as $step) {
foreach ($step->getSignatures() as $signature) {
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
return self::FORCE_DENIED;
}
}
}
}
// if all workflows are finalized negative (= canceled), we should abstain
$runningWorkflows = [];
foreach ($entityWorkflows as $entityWorkflow) {
$isFinalNegative = false;
if ($entityWorkflow->isFinal()) {
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if (isset($placeMetadata['isFinalPositive']) && false === $placeMetadata['isFinalPositive']) {
$isFinalNegative = true;
}
}
}
if (!$isFinalNegative) {
$runningWorkflows[] = $entityWorkflow;
}
}
if ([] === $runningWorkflows) {
return self::ABSTAIN;
}
// allow only the users involved
if ($this->isUserInvolvedInAWorkflow($runningWorkflows)) {
return self::FORCE_GRANT;
}
if ($isAttached) {
return self::ABSTAIN;
}
return self::FORCE_DENIED;
}

View File

@@ -0,0 +1,63 @@
<?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\MainBundle\Workflow\Helper;
/**
* Helper to give supplementary permissions to a related entity.
*
* If a related entity is associated within a workflow, the logic of the workflow can give more permissions, or
* remove some permissions.
*
* The methods of this helper return either:
*
* - FORCE_GRANT, which means that a permission can be given, even if it would be denied when the related
* entity is not associated with a workflow;
* - FORCE_DENIED, which means that a permission should be denied, even if it would be granted when the related entity
* is not associated with a workflow
* - ABSTAIN, if there is no workflow logic to add or remove permission
*
* For read operations:
*
* - if the user is involved in the workflow (is part of the current step, of a step before), the user is granted read
* operation;
* - if there is a pending signature for a person, the workflow grant access to the related entity;
* - if there a signature applyied in less than 12 hours, the workflow grant access to the related entity. This allow to
* show the related entity to the person during this time frame.
*
*
* For write operation:
*
* - if the workflow is finalized "positive" (means "not canceled"), the workflow denys write operations;
* - if there isn't any finalized "positive" workflow, and if there is a signature appliyed for a running workflow (not finalized nor canceled),
* the workflow denys write operations;
* - if there is no case above and the user is involved in the workflow (is part of the current step, of a step before), the user is granted;
*/
interface WorkflowRelatedEntityPermissionHelperInterface
{
public const FORCE_GRANT = 'FORCE_GRANT';
public const FORCE_DENIED = 'FORCE_DENIED';
public const ABSTAIN = 'ABSTAIN';
/**
* @param object $entity The entity may be an object that is a related entity of a workflow, or an EntityWorkflow itself
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForReadOperation(object $entity): string;
/**
* @param object $entity the entity may be an object which is the related entity of a workflow
*
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
*/
public function isAllowedByWorkflowForWriteOperation(object $entity): string;
}

View File

@@ -115,3 +115,7 @@ services:
$vienEntityInfoProviders: !tagged_iterator chill_main.entity_info_provider
Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler: ~
Chill\MainBundle\Service\VersionProvider:
arguments:
$packageName: 'chill-project/chill-bundles'

View File

@@ -66,3 +66,7 @@ services:
resource: './../../Templating/Listing'
Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface: '@Chill\MainBundle\Templating\Listing\FilterOrderHelperFactory'
Chill\MainBundle\Templating\VersionRenderExtension:
tags:
- { name: twig.extension }

View File

@@ -48,6 +48,9 @@ See: Voir
Name: Nom
Label: Nom
footer:
Running chill version %version%: "Version de Chill: %version%"
user:
current_user: Utilisateur courant
profile:

View File

@@ -13,14 +13,63 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\EvaluationRepository;
use Symfony\Component\HttpFoundation\Request;
class EvaluationController extends CRUDController
{
public function __construct(private readonly EvaluationRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredEvaluations($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredEvaluations($queryString, $activeFilter, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage());
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredEvaluations(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\GoalRepository;
use Symfony\Component\HttpFoundation\Request;
class GoalController extends CRUDController
{
public function __construct(private readonly GoalRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredGoals($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredGoals(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredGoals(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\ResultRepository;
use Symfony\Component\HttpFoundation\Request;
class ResultController extends CRUDController
{
public function __construct(private readonly ResultRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredResults($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredResults(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredResults(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,14 +13,68 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\SocialActionRepository;
use Symfony\Component\HttpFoundation\Request;
class SocialActionController extends CRUDController
{
public function __construct(private readonly SocialActionRepository $repository) {}
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.ordering', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredSocialActions($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredSocialActions(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredSocialActions(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -13,11 +13,15 @@ namespace Chill\PersonBundle\Controller\SocialWork;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\PersonBundle\Repository\SocialWork\SocialIssueRepository;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
class SocialIssueController extends CRUDController
{
public function __construct(private readonly SocialIssueRepository $repository) {}
protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface
{
if ('new' === $action) {
@@ -37,4 +41,54 @@ class SocialIssueController extends CRUDController
return parent::orderQuery($action, $query, $request, $paginator);
}
protected function getQueryResult(
string $action,
Request $request,
int $totalItems,
PaginatorInterface $paginator,
?FilterOrderHelper $filterOrder = null,
) {
if (0 === $totalItems) {
return [];
}
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->repository->countFilteredSocialIssues($queryString, $activeFilter);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->repository->findFilteredSocialIssues(
$queryString,
$activeFilter,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
}
protected function countEntities(string $action, Request $request, ?FilterOrderHelper $filterOrder = null): int
{
if (!$filterOrder instanceof FilterOrderHelper) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->repository->countFilteredSocialIssues(
$filterOrder->getQueryString(),
$filterOrder->getCheckboxData('activeFilter')
);
}
protected function buildFilterOrderHelper(string $action, Request $request): ?FilterOrderHelper
{
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
}

View File

@@ -38,7 +38,7 @@ class SocialWorkEvaluationApiController extends AbstractController
$pagination->getCurrentPageFirstItemNumber(),
$pagination->getItemsPerPage()
);
$collection = new Collection($evaluations, $pagination);
$collection = new Collection(array_values($evaluations), $pagination);
return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['read']]);
}

View File

@@ -25,14 +25,15 @@ class SocialWorkGoalApiController extends ApiController
public function listBySocialAction(Request $request, SocialAction $action): Response
{
$totalItems = $this->goalRepository->countBySocialActionWithDescendants($action);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$totalItems = $this->goalRepository->countBySocialActionWithDescendants($action, true);
$paginator = $this->paginator->create($totalItems);
$entities = $this->goalRepository->findBySocialActionWithDescendants(
$action,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true
);
$model = new Collection($entities, $paginator);

View File

@@ -25,14 +25,15 @@ class SocialWorkResultApiController extends ApiController
public function listByGoal(Request $request, Goal $goal): Response
{
$totalItems = $this->resultRepository->countByGoal($goal);
$totalItems = $this->resultRepository->countByGoal($goal, true);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$entities = $this->resultRepository->findByGoal(
$goal,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true,
);
$model = new Collection($entities, $paginator);
@@ -42,14 +43,15 @@ class SocialWorkResultApiController extends ApiController
public function listBySocialAction(Request $request, SocialAction $action): Response
{
$totalItems = $this->resultRepository->countBySocialActionWithDescendants($action);
$totalItems = $this->resultRepository->countBySocialActionWithDescendants($action, true);
$paginator = $this->getPaginatorFactory()->create($totalItems);
$entities = $this->resultRepository->findBySocialActionWithDescendants(
$action,
['id' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$paginator->getCurrentPageFirstItemNumber(),
onlyActive: true
);
$model = new Collection($entities, $paginator);

View File

@@ -19,6 +19,7 @@ use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class CenterAggregator implements AggregatorInterface
{
@@ -27,6 +28,7 @@ final readonly class CenterAggregator implements AggregatorInterface
public function __construct(
private CenterRepositoryInterface $centerRepository,
private RollingDateConverterInterface $rollingDateConverter,
private TranslatorInterface $translator,
) {}
public function buildForm(FormBuilderInterface $builder): void
@@ -62,7 +64,7 @@ final readonly class CenterAggregator implements AggregatorInterface
{
return function (int|string|null $value) {
if (null === $value || '' === $value) {
return '';
return $this->translator->trans('person.export.aggregator.by_center.no_center');
}
if ('_header' === $value) {
@@ -94,15 +96,18 @@ final readonly class CenterAggregator implements AggregatorInterface
$atDate = 'pers_center_agg_at_date';
$qb->leftJoin('person.centerHistory', $alias);
$qb
->andWhere(
$qb->expr()->lte($alias.'.startDate', ':'.$atDate),
)->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.endDate'),
$qb->expr()->gt($alias.'.endDate', ':'.$atDate)
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.id'),
$qb->expr()->andX(
$qb->expr()->lte($alias.'.startDate', ':'.$atDate),
$qb->expr()->orX(
$qb->expr()->isNull($alias.'.endDate'),
$qb->expr()->gt($alias.'.endDate', ':'.$atDate)
)
)
);
)
);
$qb->setParameter($atDate, $this->rollingDateConverter->convert($data['at_date']));
$qb->addSelect("IDENTITY({$alias}.center) AS ".self::COLUMN_NAME);

View File

@@ -44,12 +44,10 @@ final readonly class FilterListAccompanyingPeriodHelper implements FilterListAcc
};
// add filtering on confidential accompanying period. The confidential is applyed on the current status of
// the accompanying period (we do not use the 'calc_date' here
$aclConditionsOrX = $qb->expr()->orX(
// either the current user is the refferer for the course
'acp.user = :list_acp_current_user',
);
$qb->setParameter('list_acp_current_user', $user);
// the accompanying period (we do not use the 'calc_date' here)
//
// IMPORTANT: we must NOT bypass selected centers just because the current user is the referrer.
$aclConditionsOrX = $qb->expr()->orX();
$i = 0;
foreach ($centers as $center) {
@@ -93,6 +91,12 @@ final readonly class FilterListAccompanyingPeriodHelper implements FilterListAcc
++$i;
}
$qb->andWhere($aclConditionsOrX);
// Prevent invalid/empty WHERE when no conditions were added (e.g., no centers available)
if (0 === count($aclConditionsOrX->getParts())) {
// No allowed conditions => return no rows
$qb->andWhere('1 = 0');
} else {
$qb->andWhere($aclConditionsOrX);
}
}
}

View File

@@ -100,9 +100,9 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
$rsm->addRootEntityFromClassMetadata(AccompanyingPeriodWork::class, 'w');
$sql = "SELECT {$rsm} FROM chill_person_accompanying_period_work w
LEFT JOIN chill_person_accompanying_period_work_referrer AS rw ON accompanyingperiodwork_id = w.id
AND (rw.enddate IS NULL OR rw.enddate > CURRENT_DATE)
WHERE accompanyingPeriod_id = :periodId";
LEFT JOIN chill_person_accompanying_period_work_referrer AS rw ON accompanyingperiodwork_id = w.id
AND (rw.enddate IS NULL OR rw.enddate > CURRENT_DATE)
WHERE accompanyingPeriod_id = :periodId";
// implement filters
@@ -136,11 +136,14 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
}
// set limit and offset
$sql .= " ORDER BY
CASE WHEN w.enddate IS NULL THEN '-infinity'::timestamp ELSE 'infinity'::timestamp END ASC,
w.startdate DESC,
w.enddate DESC,
w.id DESC";
$sql .= ' ORDER BY
CASE
WHEN w.enddate IS NULL OR w.enddate > CURRENT_DATE THEN 0
ELSE 1
END ASC,
w.startdate DESC,
w.enddate DESC,
w.id DESC';
$sql .= ' LIMIT :limit OFFSET :offset';

View File

@@ -14,12 +14,16 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\Evaluation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class EvaluationRepository implements EvaluationRepositoryInterface
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(Evaluation::class);
}
@@ -65,4 +69,86 @@ final readonly class EvaluationRepository implements EvaluationRepositoryInterfa
{
return Evaluation::class;
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('e');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('e.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Evaluation::class, 'e');
// Extract the current locale's value from the JSON `title` and search on it
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(e.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Evaluation::class, 'e');
}
// Add condition based on active/inactive status
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere('e.active = true');
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere('e.active = false');
}
return $qb;
}
public function findFilteredEvaluations(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['title' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredEvaluations(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(e)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
}

View File

@@ -15,21 +15,28 @@ use Chill\PersonBundle\Entity\SocialWork\Goal;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Clock\ClockInterface;
final readonly class GoalRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
public function __construct(
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
private RequestStack $requestStack,
) {
$this->repository = $entityManager->getRepository(Goal::class);
}
public function countBySocialActionWithDescendants(SocialAction $action): int
public function countBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): int
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('COUNT(g)');
return $qb
@@ -64,9 +71,9 @@ final readonly class GoalRepository implements ObjectRepository
/**
* @return Goal[]
*/
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null): array
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('g');
$qb->andWhere(
@@ -101,7 +108,103 @@ final readonly class GoalRepository implements ObjectRepository
return Goal::class;
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action): QueryBuilder
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('g');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('g.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Goal::class, 'g');
// search across locales by extracting the localized value
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(g.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Goal::class, 'g');
}
// Active when desactivationDate is null or in the future
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('g.desactivationDate'),
$qb->expr()->gt('g.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('g.desactivationDate'),
$qb->expr()->lte('g.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, Goal>
*/
public function findFilteredGoals(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['id' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredGoals(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(g)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action, bool $onlyActive): QueryBuilder
{
$actions = $action->getDescendantsWithThis();
@@ -116,6 +219,11 @@ final readonly class GoalRepository implements ObjectRepository
}
$qb->where($orx);
if ($onlyActive) {
$qb->andWhere('g.desactivationDate > :now OR g.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
}

View File

@@ -16,21 +16,28 @@ use Chill\PersonBundle\Entity\SocialWork\Result;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Clock\ClockInterface;
final readonly class ResultRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
public function __construct(
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
private RequestStack $requestStack,
) {
$this->repository = $entityManager->getRepository(Result::class);
}
public function countByGoal(Goal $goal): int
public function countByGoal(Goal $goal, bool $onlyActive = false): int
{
$qb = $this->buildQueryByGoal($goal);
$qb = $this->buildQueryByGoal($goal, $onlyActive);
$qb->select('COUNT(r)');
return $qb
@@ -38,9 +45,9 @@ final readonly class ResultRepository implements ObjectRepository
->getSingleScalarResult();
}
public function countBySocialActionWithDescendants(SocialAction $action): int
public function countBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): int
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('COUNT(r)');
return $qb
@@ -75,9 +82,9 @@ final readonly class ResultRepository implements ObjectRepository
/**
* @return array<Result>
*/
public function findByGoal(Goal $goal, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
public function findByGoal(Goal $goal, ?array $orderBy = null, ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryByGoal($goal);
$qb = $this->buildQueryByGoal($goal, $onlyActive);
if (null !== $orderBy) {
foreach ($orderBy as $sort => $order) {
@@ -96,9 +103,9 @@ final readonly class ResultRepository implements ObjectRepository
/**
* @return Result[]
*/
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null): array
public function findBySocialActionWithDescendants(SocialAction $action, array $orderBy = [], ?int $limit = null, ?int $offset = null, bool $onlyActive = false): array
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb = $this->buildQueryBySocialActionWithDescendants($action, $onlyActive);
$qb->select('r');
foreach ($orderBy as $sort => $order) {
@@ -125,17 +132,116 @@ final readonly class ResultRepository implements ObjectRepository
return Result::class;
}
private function buildQueryByGoal(Goal $goal): QueryBuilder
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('r');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('r.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(Result::class, 'r');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(r.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(Result::class, 'r');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('r.desactivationDate'),
$qb->expr()->gt('r.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('r.desactivationDate'),
$qb->expr()->lte('r.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, Result>
*/
public function findFilteredResults(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['id' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredResults(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(r)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryByGoal(Goal $goal, bool $onlyActive): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('r');
$qb->where(':goal MEMBER OF r.goals')
->setParameter('goal', $goal);
if ($onlyActive) {
$qb->andWhere('r.desactivationDate > :now OR r.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action): QueryBuilder
private function buildQueryBySocialActionWithDescendants(SocialAction $action, bool $onlyActive = false): QueryBuilder
{
$actions = $action->getDescendantsWithThis();
@@ -150,6 +256,11 @@ final readonly class ResultRepository implements ObjectRepository
}
$qb->where($orx);
if ($onlyActive) {
$qb->andWhere('r.desactivationDate > :now OR r.desactivationDate IS NULL')
->setParameter('now', $this->clock->now());
}
return $qb;
}
}

View File

@@ -14,14 +14,17 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class SocialActionRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(SocialAction::class);
}
@@ -84,6 +87,100 @@ final readonly class SocialActionRepository implements ObjectRepository
return SocialAction::class;
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('sa');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('sa.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(SocialAction::class, 'sa');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(sa.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(SocialAction::class, 'sa');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('sa.desactivationDate'),
$qb->expr()->gt('sa.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('sa.desactivationDate'),
$qb->expr()->lte('sa.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, SocialAction>
*/
public function findFilteredSocialActions(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['ordering' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredSocialActions(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(sa)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryWithDesactivatedDateCriteria(): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('sa');

View File

@@ -14,14 +14,17 @@ namespace Chill\PersonBundle\Repository\SocialWork;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class SocialIssueRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager, private RequestStack $requestStack)
{
$this->repository = $entityManager->getRepository(SocialIssue::class);
}
@@ -79,6 +82,100 @@ final readonly class SocialIssueRepository implements ObjectRepository
return SocialIssue::class;
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('si');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('si.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function getLang(): string
{
return $this->requestStack->getCurrentRequest()?->getLocale() ?? 'fr';
}
private function queryByTitle(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(SocialIssue::class, 'si');
$qb
->where($qb->expr()->like('LOWER(UNACCENT(JSON_EXTRACT(si.title, :lang)))', "CONCAT('%', LOWER(UNACCENT(:pattern)), '%')"))
->setParameter('pattern', $pattern)
->setParameter('lang', $this->getLang());
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive): QueryBuilder
{
if (null !== $queryString) {
$qb = $this->queryByTitle($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(SocialIssue::class, 'si');
}
$now = new \DateTime('now');
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('si.desactivationDate'),
$qb->expr()->gt('si.desactivationDate', ':now')
)
)->setParameter('now', $now);
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->isNotNull('si.desactivationDate'),
$qb->expr()->lte('si.desactivationDate', ':now')
)
)->setParameter('now', $now);
}
return $qb;
}
/**
* @return array<int, SocialIssue>
*/
public function findFilteredSocialIssues(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['ordering' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredSocialIssues(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(si)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
private function buildQueryWithDesactivatedDateCriteria(): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('si');

View File

@@ -8,8 +8,8 @@
:editor="ClassicEditor"
:config="classicEditorConfig"
:placeholder="trans(EVALUATION_COMMENT_PLACEHOLDER)"
:value="comment"
@input="$emit('update:comment', $event)"
:model-value="comment"
@update:model-value="$emit('update:comment', $event)"
tag-name="textarea"
></ckeditor>
</div>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Title'|trans }}</th>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Title'|trans }}</th>
@@ -22,7 +25,7 @@
</td>
<td>
{% if entity.desactivationDate is not null %}
{{ entity.desactivationDate|date('Y-m-d') }}
{{ entity.desactivationDate|format_date('medium') }}
{% endif %}
</td>
<td>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Title'|trans }}</th>
@@ -16,7 +19,7 @@
<td>{{ entity.title|localize_translatable_string }}</td>
<td>
{% if entity.desactivationDate is not null %}
{{ entity.desactivationDate|date('Y-m-d') }}
{{ entity.desactivationDate|format_date('medium') }}
{% endif %}
</td>
<td>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id' }}</th>
<th>{{ 'Title'|trans }}</th>

View File

@@ -2,6 +2,9 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Title'|trans }}</th>

View File

@@ -14,6 +14,7 @@ namespace Chill\PersonBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocumentRepository;
@@ -26,8 +27,9 @@ class AccompanyingPeriodWorkEvaluationDocumentStoredObjectVoter extends Abstract
private readonly AccompanyingPeriodWorkEvaluationDocumentRepository $repository,
Security $security,
WorkflowRelatedEntityPermissionHelper $workflowDocumentService,
EntityWorkflowAttachmentRepository $attachmentRepository,
) {
parent::__construct($security, $workflowDocumentService);
parent::__construct($security, $attachmentRepository, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface

View File

@@ -103,6 +103,11 @@ Employment status: Situation professionelle
Administrative status: Situation administrative
person:
# trans key according to new conventions
export:
aggregator:
by_center:
no_center: Sans territoire
Identifiers: Identifiants