mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 01:08:26 +00:00 
			
		
		
		
	Compare commits
	
		
			189 Commits
		
	
	
		
			v3.0.0-RC8
			...
			signature-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | bf61324c1d | ||
|  | abf20b0cf2 | ||
|  | 86896a12e6 | ||
|  | 3a959b7044 | ||
|  | f8d95384ea | ||
| 8a374864fa | |||
| bb848746d5 | |||
| 3738c110f8 | |||
| f57fdb2b4c | |||
| b57824fc7e | |||
| 6b4e1ed2d3 | |||
| b0485dbcc8 | |||
| c16219dc6d | |||
| ad47804c91 | |||
| 85e2466611 | |||
| 94d6b5eff8 | |||
| d87f380f16 | |||
| 58bf722fae | |||
| 50fb79ebbf | |||
| 58912f1d98 | |||
| 9604ba5f4b | |||
| b689a51a48 | |||
| 8c0d2f58ba | |||
| 212230448b | |||
| 2bfb8fe387 | |||
| 6362b98a00 | |||
| 6e2a08cae8 | |||
| 305105faae | |||
| 85811cc6ae | |||
| 7eee995627 | |||
| c0c448fb39 | |||
| 6445342136 | |||
| d52e54fd2a | |||
| 547a9d1369 | |||
| 288a02f5b7 | |||
| 2f9884072c | |||
| ee45ff61a6 | |||
| 564813ef3d | |||
| 5fed42a623 | |||
| b19dd4fc11 | |||
| 44226d6f7f | |||
| d75607a1d2 | |||
| bf66af0f25 | |||
| 15f3e474a0 | |||
| 5623cf946e | |||
| 0a6f3a99da | |||
| 50bd9f32c3 | |||
| 1396304af5 | |||
| c33e4adeec | |||
| 7351a35c42 | |||
| 72e3325626 | |||
| 0a46b5304d | |||
|  | e57d52d00e | ||
| 64e527672d | |||
| 123168a5ee | |||
| 3836d0dc9b | |||
| 51ab4bef38 | |||
|  | 567ca8a26f | ||
|  | 111305d09c | ||
|  | 67395f52b5 | ||
|  | 421226c0dc | ||
|  | 77da2c1ac6 | ||
|  | 39d3ba2f40 | ||
|  | fb62e54d63 | ||
|  | c968d6c541 | ||
|  | c428e6665f | ||
|  | 5b7e3f0336 | ||
|  | 0c8ef37860 | ||
|  | 794c479b9e | ||
|  | 1bee3114ac | ||
|  | 1344b65dd4 | ||
|  | 68dcf4dd28 | ||
|  | b0a8fd54a8 | ||
|  | 0f589ec57e | ||
|  | 2d4fc45a0c | ||
|  | c80f23f0db | ||
|  | c950400fe2 | ||
|  | 21c1e77d36 | ||
|  | bbfd0caf10 | ||
|  | 9192883217 | ||
| 3836622d27 | |||
| cc2c4be1b0 | |||
| 873940786f | |||
| db73dcffc7 | |||
| 8aec69f0f9 | |||
| 9f88eef249 | |||
| d689ce9aef | |||
| d5e4991982 | |||
| ca68b58246 | |||
| 747a1de321 | |||
| 9e92ede16f | |||
| 31f842471a | |||
| 7d0f9175be | |||
| e83307ca6d | |||
| 215eba41b7 | |||
| 52a3d1be1b | |||
| 8d543be5cc | |||
| 0474b25859 | |||
| db94af0958 | |||
| 3e8805bdda | |||
| a887602f4f | |||
| c1cf27c42d | |||
| fe6b4848e6 | |||
| b5af9f7b63 | |||
| 7f3de62b2c | |||
| cfa51cd659 | |||
| facc4affed | |||
| f9122341d1 | |||
| 7dd5f542a6 | |||
| 3b80d9a93b | |||
| 790576863f | |||
| 25e89571f7 | |||
| 435836c7d1 | |||
| af4db22184 | |||
| 2adc8b3bf6 | |||
| 21b79c1981 | |||
| 428494ca1f | |||
| 5d57ec8a3b | |||
| 719fabc878 | |||
| e9a9a3430f | |||
| c648a560cc | |||
| 3d7c8596ee | |||
| 345f379650 | |||
| 3262a1dd02 | |||
| a9f4f8c973 | |||
| c19c597ba0 | |||
| 03800029c9 | |||
| 064dfc5a56 | |||
| ba95687f46 | |||
| a309cc0774 | |||
| 5b0babb9b0 | |||
| ac2f314395 | |||
| 8c92d11722 | |||
| 3db4fff80d | |||
| fb743b522d | |||
| d1653a074b | |||
| 254122d125 | |||
| c9d2e37cee | |||
| c9d54a5fea | |||
| 86c862e69d | |||
| 9bc6fe6aff | |||
| 18a03fd740 | |||
| e9d4b9e2ab | |||
| efaad1981d | |||
| 742f2540f6 | |||
| bab6528ed6 | |||
| a25f2c7539 | |||
| c06e76a0ee | |||
| 4607c36b57 | |||
| 7c03a25f1a | |||
| cce04ee490 | |||
| e54633d14d | |||
| d9892f6822 | |||
| f75c7a0232 | |||
| 062afd6695 | |||
| 830dace1ba | |||
| 2ce9810243 | |||
| 26b3d84d62 | |||
| 30078db841 | |||
| aaac80be84 | |||
| a0fead48e1 | |||
| 2d09efb2e0 | |||
| 3a87513a11 | |||
| d3956319ca | |||
| bd36735cb1 | |||
| 1310d53589 | |||
| 610239930b | |||
| b65e2c62c4 | |||
| 89f5231649 | |||
| 73797b98f6 | |||
| 3d40db7493 | |||
| 760d65b972 | |||
| d26fa6bde6 | |||
| 427f232ab8 | |||
| 99818c211d | |||
| a9f0059743 | |||
| 5bc542a567 | |||
| 482f279dc5 | |||
| e0828b1f0f | |||
| e015f71bb0 | |||
| 04a48f22ad | |||
| ad4fe80240 | |||
| 4b82e67952 | |||
| c8ccce83fd | |||
| e9a9262fae | |||
| d9e37d0958 | |||
| 65c41e6fa9 | |||
| 7923b5a1ef | |||
| 4a229ebf6b | 
							
								
								
									
										8
									
								
								.changes/unreleased/Feature-20240614-153236.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.changes/unreleased/Feature-20240614-153236.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| kind: Feature | ||||
| body: |- | ||||
|   Electronic signature | ||||
|  | ||||
|   Implementation of the electronic signature for documents within chill. | ||||
| time: 2024-06-14T15:32:36.875891692+02:00 | ||||
| custom: | ||||
|   Issue: "" | ||||
							
								
								
									
										7
									
								
								.changes/unreleased/Feature-20240614-153537.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.changes/unreleased/Feature-20240614-153537.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| kind: Feature | ||||
| body: The behavoir of the voters for stored objects is adjusted so as to limit edit | ||||
|   and delete possibilities to users related to the activity, social action or workflow | ||||
|   entity. | ||||
| time: 2024-06-14T15:35:37.582159301+02:00 | ||||
| custom: | ||||
|   Issue: "286" | ||||
							
								
								
									
										5
									
								
								.changes/unreleased/Feature-20240718-151233.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.changes/unreleased/Feature-20240718-151233.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| kind: Feature | ||||
| body: Metadata form added for person signatures | ||||
| time: 2024-07-18T15:12:33.8134266+02:00 | ||||
| custom: | ||||
|   Issue: "288" | ||||
| @@ -1,3 +0,0 @@ | ||||
| ## v2.23.1 - 2024-07-25 | ||||
| ### Fixed | ||||
| * Fix export activities linked to accompanying period between two dates  | ||||
							
								
								
									
										5
									
								
								.changes/v3.0.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.changes/v3.0.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| ## v3.0.0 - 2024-08-26 | ||||
| ### Fixed | ||||
| * Fix delete action for accompanying periods in draft state  | ||||
| * Fix connection to azure when making an calendar event in chill  | ||||
| * CollectionType js fixes for remove button and adding multiple entries  | ||||
| @@ -138,4 +138,4 @@ release: | ||||
|         - echo "running release_job" | ||||
|     release: | ||||
|         tag_name: '$CI_COMMIT_TAG' | ||||
|         description: "./.changes/v$CI_COMMIT_TAG.md" | ||||
|         description: "./.changes/$CI_COMMIT_TAG.md" | ||||
|   | ||||
| @@ -6,6 +6,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), | ||||
| and is generated by [Changie](https://github.com/miniscruff/changie). | ||||
|  | ||||
|  | ||||
| ## v3.0.0 - 2024-08-26 | ||||
| ### Fixed | ||||
| * Fix delete action for accompanying periods in draft state  | ||||
| * Fix connection to azure when making an calendar event in chill  | ||||
| * CollectionType js fixes for remove button and adding multiple entries  | ||||
|  | ||||
| ## v2.23.0 - 2024-07-23 | ||||
| ### Feature | ||||
| * ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles  | ||||
|   | ||||
| @@ -31,6 +31,7 @@ | ||||
|         "phpoffice/phpspreadsheet": "^1.16", | ||||
|         "ramsey/uuid-doctrine": "^1.7", | ||||
|         "sensio/framework-extra-bundle": "^5.5", | ||||
|         "smalot/pdfparser": "^2.10", | ||||
|         "spomky-labs/base64url": "^2.0", | ||||
|         "symfony/asset": "^5.4", | ||||
|         "symfony/browser-kit": "^5.4", | ||||
|   | ||||
| @@ -53,6 +53,7 @@ | ||||
|     "marked": "^12.0.2", | ||||
|     "masonry-layout": "^4.2.2", | ||||
|     "mime": "^4.0.0", | ||||
|     "pdfjs-dist": "^4.3.136", | ||||
|     "swagger-ui": "^4.15.5", | ||||
|     "vis-network": "^9.1.0", | ||||
|     "vue": "^3.2.37", | ||||
|   | ||||
| @@ -69,9 +69,8 @@ return static function (RectorConfig $rectorConfig): void { | ||||
|  | ||||
|     // skip some path... | ||||
|     $rectorConfig->skip([ | ||||
|         // we must adapt service definition | ||||
|         \Rector\Symfony\Symfony28\Rector\MethodCall\GetToConstructorInjectionRector::class, | ||||
|         \Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector::class, | ||||
|         // waiting for fixing this bug: https://github.com/rectorphp/rector-doctrine/issues/342 | ||||
|         \Rector\Doctrine\CodeQuality\Rector\Property\ImproveDoctrineCollectionDocTypeInEntityRector::class, | ||||
|     ]); | ||||
|  | ||||
|     $rectorConfig->ruleWithConfiguration(AnnotationToAttributeRector::class, [ | ||||
|   | ||||
| @@ -80,7 +80,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac | ||||
|     private \DateTime $date; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<StoredObject> | ||||
|      * @var Collection<int, StoredObject> | ||||
|      */ | ||||
|     #[Assert\Valid(traverse: true)] | ||||
|     #[ORM\ManyToMany(targetEntity: StoredObject::class, cascade: ['persist'])] | ||||
| @@ -107,7 +107,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac | ||||
|     private ?Person $person = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<Person> | ||||
|      * @var Collection<int, \Chill\PersonBundle\Entity\Person> | ||||
|      */ | ||||
|     #[Groups(['read', 'docgen:read'])] | ||||
|     #[ORM\ManyToMany(targetEntity: Person::class)] | ||||
| @@ -117,7 +117,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac | ||||
|     private PrivateCommentEmbeddable $privateComment; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<ActivityReason> | ||||
|      * @var Collection<int, ActivityReason> | ||||
|      */ | ||||
|     #[Groups(['docgen:read'])] | ||||
|     #[ORM\ManyToMany(targetEntity: ActivityReason::class)] | ||||
| @@ -132,7 +132,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac | ||||
|     private string $sentReceived = ''; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<SocialAction> | ||||
|      * @var Collection<int, \Chill\PersonBundle\Entity\SocialWork\SocialAction> | ||||
|      */ | ||||
|     #[Groups(['read', 'docgen:read'])] | ||||
|     #[ORM\ManyToMany(targetEntity: SocialAction::class)] | ||||
| @@ -140,7 +140,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac | ||||
|     private Collection $socialActions; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<SocialIssue> | ||||
|      * @var Collection<int, SocialIssue> | ||||
|      */ | ||||
|     #[Groups(['read', 'docgen:read'])] | ||||
|     #[ORM\ManyToMany(targetEntity: SocialIssue::class)] | ||||
| @@ -148,7 +148,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac | ||||
|     private Collection $socialIssues; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<ThirdParty> | ||||
|      * @var Collection<int, ThirdParty> | ||||
|      */ | ||||
|     #[Groups(['read', 'docgen:read'])] | ||||
|     #[ORM\ManyToMany(targetEntity: ThirdParty::class)] | ||||
| @@ -162,7 +162,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac | ||||
|     private ?User $user = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[Groups(['read', 'docgen:read'])] | ||||
|     #[ORM\ManyToMany(targetEntity: User::class)] | ||||
|   | ||||
| @@ -40,9 +40,9 @@ class ActivityReasonCategory implements \Stringable | ||||
|     /** | ||||
|      * Array of ActivityReason. | ||||
|      * | ||||
|      * @var Collection<ActivityReason> | ||||
|      * @var Collection<int, ActivityReason> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: ActivityReason::class, mappedBy: 'category')] | ||||
|     #[ORM\OneToMany(mappedBy: 'category', targetEntity: ActivityReason::class)] | ||||
|     private Collection $reasons; | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -73,7 +73,7 @@ final readonly class PeriodHavingActivityBetweenDatesFilter implements FilterInt | ||||
|  | ||||
|         $qb->andWhere( | ||||
|             $qb->expr()->exists( | ||||
|                 'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = activity.accompanyingPeriod AND {$alias}.id = activity.id" | ||||
|                 'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = activity.accompanyingPeriod" | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|   | ||||
| @@ -12,6 +12,8 @@ declare(strict_types=1); | ||||
| namespace Chill\ActivityBundle\Repository; | ||||
|  | ||||
| use Chill\ActivityBundle\Entity\Activity; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | ||||
| @@ -23,7 +25,7 @@ use Doctrine\Persistence\ManagerRegistry; | ||||
|  * @method Activity[]    findAll() | ||||
|  * @method Activity[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) | ||||
|  */ | ||||
| class ActivityRepository extends ServiceEntityRepository | ||||
| class ActivityRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     public function __construct(ManagerRegistry $registry) | ||||
|     { | ||||
| @@ -97,4 +99,16 @@ class ActivityRepository extends ServiceEntityRepository | ||||
|  | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Activity | ||||
|     { | ||||
|         $qb = $this->createQueryBuilder('a'); | ||||
|         $query = $qb | ||||
|             ->leftJoin('a.documents', 'ad') | ||||
|             ->where('ad.id = :storedObjectId') | ||||
|             ->setParameter('storedObjectId', $storedObject->getId()) | ||||
|             ->getQuery(); | ||||
|  | ||||
|         return $query->getOneOrNullResult(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,54 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\ActivityBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\ActivityBundle\Entity\Activity; | ||||
| 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\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class ActivityStoredObjectVoter extends AbstractStoredObjectVoter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly ActivityRepository $repository, | ||||
|         Security $security, | ||||
|         WorkflowStoredObjectPermissionHelper $workflowDocumentService | ||||
|     ) { | ||||
|         parent::__construct($security, $workflowDocumentService); | ||||
|     } | ||||
|  | ||||
|     protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|     { | ||||
|         return $this->repository; | ||||
|     } | ||||
|  | ||||
|     protected function getClass(): string | ||||
|     { | ||||
|         return Activity::class; | ||||
|     } | ||||
|  | ||||
|     protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|     { | ||||
|         return match ($attribute) { | ||||
|             StoredObjectRoleEnum::EDIT => ActivityVoter::UPDATE, | ||||
|             StoredObjectRoleEnum::SEE => ActivityVoter::SEE_DETAILS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     protected function canBeAssociatedWithWorkflow(): bool | ||||
|     { | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
| @@ -22,9 +22,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
| class AsideActivityCategory | ||||
| { | ||||
|     /** | ||||
|      * @var Collection<AsideActivityCategory> | ||||
|      * @var Collection<int, AsideActivityCategory> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: AsideActivityCategory::class, mappedBy: 'parent')] | ||||
|     #[ORM\OneToMany(mappedBy: 'parent', targetEntity: AsideActivityCategory::class)] | ||||
|     private Collection $children; | ||||
|  | ||||
|     #[ORM\Id] | ||||
|   | ||||
| @@ -103,7 +103,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente | ||||
|     private int $dateTimeVersion = 0; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<CalendarDoc> | ||||
|      * @var Collection<int, \Chill\CalendarBundle\Entity\CalendarDoc> | ||||
|      */ | ||||
|     #[ORM\OneToMany(mappedBy: 'calendar', targetEntity: CalendarDoc::class, orphanRemoval: true)] | ||||
|     private Collection $documents; | ||||
| @@ -120,7 +120,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection&Selectable<int, Invite> | ||||
|      * @var \Doctrine\Common\Collections\Collection<int, \Chill\CalendarBundle\Entity\Invite>&Selectable | ||||
|      */ | ||||
|     #[Serializer\Groups(['read', 'docgen:read'])] | ||||
|     #[ORM\OneToMany(mappedBy: 'calendar', targetEntity: Invite::class, cascade: ['persist', 'remove', 'merge', 'detach'], orphanRemoval: true)] | ||||
| @@ -143,7 +143,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente | ||||
|     private ?Person $person = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<Person> | ||||
|      * @var Collection<int, Person> | ||||
|      */ | ||||
|     #[Serializer\Groups(['calendar:read', 'read', 'calendar:light', 'docgen:read'])] | ||||
|     #[Assert\Count(min: 1, minMessage: 'calendar.At least {{ limit }} person is required.')] | ||||
| @@ -157,7 +157,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente | ||||
|     private PrivateCommentEmbeddable $privateComment; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<ThirdParty> | ||||
|      * @var Collection<int, ThirdParty> | ||||
|      */ | ||||
|     #[Serializer\Groups(['calendar:read', 'read', 'calendar:light', 'docgen:read'])] | ||||
|     #[ORM\ManyToMany(targetEntity: ThirdParty::class)] | ||||
|   | ||||
| @@ -47,7 +47,7 @@ final class CalendarContextTest extends TestCase | ||||
|     { | ||||
|         $expected = | ||||
|             [ | ||||
|                 'track_datetime' => true, | ||||
|                 'trackDatetime' => true, | ||||
|                 'askMainPerson' => true, | ||||
|                 'mainPersonLabel' => 'docgen.calendar.Destinee', | ||||
|                 'askThirdParty' => false, | ||||
| @@ -61,7 +61,7 @@ final class CalendarContextTest extends TestCase | ||||
|     { | ||||
|         $expected = | ||||
|             [ | ||||
|                 'track_datetime' => true, | ||||
|                 'trackDatetime' => true, | ||||
|                 'askMainPerson' => true, | ||||
|                 'mainPersonLabel' => 'docgen.calendar.Destinee', | ||||
|                 'askThirdParty' => false, | ||||
|   | ||||
| @@ -23,9 +23,9 @@ class Option | ||||
|     private bool $active = true; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<Option> | ||||
|      * @var Collection<int, Option> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: Option::class, mappedBy: 'parent')] | ||||
|     #[ORM\OneToMany(mappedBy: 'parent', targetEntity: Option::class)] | ||||
|     private Collection $children; | ||||
|  | ||||
|     #[ORM\Id] | ||||
|   | ||||
| @@ -32,9 +32,9 @@ class CustomFieldsGroup | ||||
|      * The custom fields of the group. | ||||
|      * The custom fields are asc-ordered regarding to their property "ordering". | ||||
|      * | ||||
|      * @var Collection<CustomField> | ||||
|      * @var Collection<int, CustomField> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: CustomField::class, mappedBy: 'customFieldGroup')] | ||||
|     #[ORM\OneToMany(mappedBy: 'customFieldGroup', targetEntity: CustomField::class)] | ||||
|     #[ORM\OrderBy(['ordering' => \Doctrine\Common\Collections\Criteria::ASC])] | ||||
|     private Collection $customFields; | ||||
|  | ||||
|   | ||||
| @@ -89,6 +89,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf | ||||
|         $g = new SignedUrlPost( | ||||
|             $url = $this->generateUrl($object_name), | ||||
|             $expires, | ||||
|             $object_name, | ||||
|             $this->max_post_file_size, | ||||
|             $max_file_count, | ||||
|             $submit_delay, | ||||
| @@ -127,7 +128,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf | ||||
|         ]; | ||||
|         $url = $url.'?'.\http_build_query($args); | ||||
|  | ||||
|         $signature = new SignedUrl(strtoupper($method), $url, $expires); | ||||
|         $signature = new SignedUrl(strtoupper($method), $url, $expires, $object_name); | ||||
|  | ||||
|         $this->event_dispatcher->dispatch( | ||||
|             new TempUrlGenerateEvent($signature) | ||||
|   | ||||
| @@ -21,6 +21,8 @@ readonly class SignedUrl | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         public string $url, | ||||
|         public \DateTimeImmutable $expires, | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         public string $object_name, | ||||
|     ) {} | ||||
|  | ||||
|     #[Serializer\Groups(['read'])] | ||||
|   | ||||
| @@ -18,6 +18,7 @@ readonly class SignedUrlPost extends SignedUrl | ||||
|     public function __construct( | ||||
|         string $url, | ||||
|         \DateTimeImmutable $expires, | ||||
|         string $object_name, | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         public int $max_file_size, | ||||
|         #[Serializer\Groups(['read'])] | ||||
| @@ -31,6 +32,6 @@ readonly class SignedUrlPost extends SignedUrl | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         public string $signature, | ||||
|     ) { | ||||
|         parent::__construct('POST', $url, $expires); | ||||
|         parent::__construct('POST', $url, $expires, $object_name); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -26,6 +26,8 @@ use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
|  | ||||
| /** | ||||
|  * Class DocumentPersonController. | ||||
| @@ -40,6 +42,8 @@ class DocumentPersonController extends AbstractController | ||||
|         protected TranslatorInterface $translator, | ||||
|         protected EventDispatcherInterface $eventDispatcher, | ||||
|         protected AuthorizationHelper $authorizationHelper, | ||||
|         protected PDFSignatureZoneParser $PDFSignatureZoneParser, | ||||
|         protected StoredObjectManagerInterface $storedObjectManagerInterface, | ||||
|         private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry | ||||
|     ) {} | ||||
|  | ||||
| @@ -197,4 +201,36 @@ class DocumentPersonController extends AbstractController | ||||
|             ['document' => $document, 'person' => $person] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[Route(path: '/{id}/signature', name: 'person_document_signature', methods: 'GET')] | ||||
|     public function signature(Person $person, PersonDocument $document): Response | ||||
|     { | ||||
|         $this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person); | ||||
|         $this->denyAccessUnlessGranted('CHILL_PERSON_DOCUMENT_SEE', $document); | ||||
|  | ||||
|         $event = new PrivacyEvent($person, [ | ||||
|             'element_class' => PersonDocument::class, | ||||
|             'element_id' => $document->getId(), | ||||
|             'action' => 'show', | ||||
|         ]); | ||||
|         $this->eventDispatcher->dispatch($event, PrivacyEvent::PERSON_PRIVACY_EVENT); | ||||
|  | ||||
|         $storedObject = $document->getObject(); | ||||
|         $content = $this->storedObjectManagerInterface->read($storedObject); | ||||
|         $zones = $this->PDFSignatureZoneParser->findSignatureZones($content); | ||||
|  | ||||
|         $signature = []; | ||||
|         $signature['id'] = 1; | ||||
|         $signature['storedObject'] = [ // TEMP | ||||
|             'filename' => $storedObject->getFilename(), | ||||
|             'iv' => $storedObject->getIv(), | ||||
|             'keyInfos' => $storedObject->getKeyInfos(), | ||||
|         ]; | ||||
|         $signature['zones'] = $zones; | ||||
|  | ||||
|         return $this->render( | ||||
|             '@ChillDocStore/PersonDocument/signature.html.twig', | ||||
|             ['document' => $document, 'person' => $person, 'signature' => $signature] | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,67 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Controller; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessage; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFPage; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
|  | ||||
| class SignatureRequestController | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly MessageBusInterface $messageBus, | ||||
|         private readonly StoredObjectManagerInterface $storedObjectManager, | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')] | ||||
|     public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse | ||||
|     { | ||||
|         $entityWorkflow = $signature->getStep()->getEntityWorkflow(); | ||||
|         $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); | ||||
|         $content = $this->storedObjectManager->read($storedObject); | ||||
|  | ||||
|         $data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); // TODO parse payload: json_decode ou, mieux, dataTransfertObject | ||||
|         $zone = new PDFSignatureZone( | ||||
|             $data['zone']['index'], | ||||
|             $data['zone']['x'], | ||||
|             $data['zone']['y'], | ||||
|             $data['zone']['height'], | ||||
|             $data['zone']['width'], | ||||
|             new PDFPage($data['zone']['PDFPage']['index'], $data['zone']['PDFPage']['width'], $data['zone']['PDFPage']['height']) | ||||
|         ); | ||||
|  | ||||
|         $this->messageBus->dispatch(new RequestPdfSignMessage( | ||||
|             $signature->getId(), | ||||
|             $zone, | ||||
|             $data['zone']['index'], | ||||
|             'test signature', // reason (string) | ||||
|             'Mme Caroline Diallo', // signerText (string) | ||||
|             $content | ||||
|         )); | ||||
|  | ||||
|         return new JsonResponse(null, JsonResponse::HTTP_OK, []); | ||||
|     } | ||||
|  | ||||
|     #[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')] | ||||
|     public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse | ||||
|     { | ||||
|         return new JsonResponse($signature->getState(), JsonResponse::HTTP_OK, []); | ||||
|     } | ||||
| } | ||||
| @@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\DependencyInjection; | ||||
| use Chill\DocStoreBundle\Controller\StoredObjectApiController; | ||||
| use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; | ||||
| use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface; | ||||
| use Symfony\Component\Config\FileLocator; | ||||
| use Symfony\Component\DependencyInjection\ContainerBuilder; | ||||
| use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; | ||||
| @@ -35,6 +36,8 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf | ||||
|  | ||||
|         $container->setParameter('chill_doc_store', $config); | ||||
|  | ||||
|         $container->registerForAutoconfiguration(StoredObjectVoterInterface::class)->addTag('stored_object_voter'); | ||||
|  | ||||
|         $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); | ||||
|         $loader->load('services.yaml'); | ||||
|         $loader->load('services/controller.yaml'); | ||||
| @@ -42,6 +45,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf | ||||
|         $loader->load('services/fixtures.yaml'); | ||||
|         $loader->load('services/form.yaml'); | ||||
|         $loader->load('services/templating.yaml'); | ||||
|         $loader->load('services/security.yaml'); | ||||
|     } | ||||
|  | ||||
|     public function prepend(ContainerBuilder $container) | ||||
|   | ||||
| @@ -12,13 +12,14 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriod; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| class AccompanyingCourseDocumentRepository implements ObjectRepository | ||||
| class AccompanyingCourseDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     private readonly EntityRepository $repository; | ||||
|  | ||||
| @@ -45,6 +46,16 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository | ||||
|         return $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('d'); | ||||
|         $query = $qb->where('d.object = :storedObject') | ||||
|             ->setParameter('storedObject', $storedObject) | ||||
|             ->getQuery(); | ||||
|  | ||||
|         return $query->getOneOrNullResult(); | ||||
|     } | ||||
|  | ||||
|     public function find($id): ?AccompanyingCourseDocument | ||||
|     { | ||||
|         return $this->repository->find($id); | ||||
| @@ -55,7 +66,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     { | ||||
|         return $this->repository->findBy($criteria, $orderBy, $limit, $offset); | ||||
|     } | ||||
| @@ -65,7 +76,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository | ||||
|         return $this->findOneBy($criteria); | ||||
|     } | ||||
|  | ||||
|     public function getClassName() | ||||
|     public function getClassName(): string | ||||
|     { | ||||
|         return AccompanyingCourseDocument::class; | ||||
|     } | ||||
|   | ||||
| @@ -0,0 +1,19 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
|  | ||||
| interface AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object; | ||||
| } | ||||
| @@ -12,6 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\PersonDocument; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
| @@ -19,7 +20,7 @@ use Doctrine\Persistence\ObjectRepository; | ||||
| /** | ||||
|  * @template ObjectRepository<PersonDocument::class> | ||||
|  */ | ||||
| readonly class PersonDocumentRepository implements ObjectRepository | ||||
| readonly class PersonDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     private EntityRepository $repository; | ||||
|  | ||||
| @@ -53,4 +54,14 @@ readonly class PersonDocumentRepository implements ObjectRepository | ||||
|     { | ||||
|         return PersonDocument::class; | ||||
|     } | ||||
|  | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('d'); | ||||
|         $query = $qb->where('d.object = :storedObject') | ||||
|             ->setParameter('storedObject', $storedObject) | ||||
|             ->getQuery(); | ||||
|  | ||||
|         return $query->getOneOrNullResult(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -26,11 +26,11 @@ export interface StoredObject { | ||||
| } | ||||
|  | ||||
| export interface StoredObjectCreated { | ||||
|     status: "stored_object_created", | ||||
|     filename: string, | ||||
|     iv: Uint8Array, | ||||
|     keyInfos: object, | ||||
|     type: string, | ||||
|   status: "stored_object_created", | ||||
|   filename: string, | ||||
|   iv: Uint8Array, | ||||
|   keyInfos: object, | ||||
|   type: string, | ||||
| } | ||||
|  | ||||
| export interface StoredObjectStatusChange { | ||||
| @@ -51,14 +51,37 @@ export type WopiEditButtonExecutableBeforeLeaveFunction = { | ||||
|  * Object containing information for performering a POST request to a swift object store | ||||
|  */ | ||||
| export interface PostStoreObjectSignature { | ||||
|     method: "POST", | ||||
|     max_file_size: number, | ||||
|     max_file_count: 1, | ||||
|     expires: number, | ||||
|     submit_delay: 180, | ||||
|     redirect: string, | ||||
|     prefix: string, | ||||
|     url: string, | ||||
|     signature: string, | ||||
|   method: "POST", | ||||
|   max_file_size: number, | ||||
|   max_file_count: 1, | ||||
|   expires: number, | ||||
|   submit_delay: 180, | ||||
|   redirect: string, | ||||
|   prefix: string, | ||||
|   url: string, | ||||
|   signature: string, | ||||
| } | ||||
|  | ||||
| export interface PDFPage { | ||||
|   index: number, | ||||
|   width: number, | ||||
|   height: number, | ||||
| } | ||||
| export interface SignatureZone { | ||||
|   index: number | null, | ||||
|   x: number, | ||||
|   y: number, | ||||
|   width: number, | ||||
|   height: number, | ||||
|   PDFPage: PDFPage, | ||||
| } | ||||
|  | ||||
| export interface Signature { | ||||
|   id: number, | ||||
|   storedObject: StoredObject, | ||||
|   zones: SignatureZone[], | ||||
| } | ||||
|  | ||||
| export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error'; | ||||
|  | ||||
| export type CanvasEvent = 'select' | 'add'; | ||||
| @@ -0,0 +1,559 @@ | ||||
| <template> | ||||
|   <teleport to="body"> | ||||
|     <modal v-if="modalOpen" @close="modalOpen = false"> | ||||
|       <template v-slot:header> | ||||
|         <h2>{{ $t("signature_confirmation") }}</h2> | ||||
|       </template> | ||||
|       <template v-slot:body> | ||||
|         <div class="signature-modal-body text-center" v-if="loading"> | ||||
|           <p>{{ $t("electronic_signature_in_progress") }}</p> | ||||
|           <div class="loading"> | ||||
|             <i | ||||
|               class="fa fa-circle-o-notch fa-spin fa-3x" | ||||
|               :title="$t('loading')" | ||||
|             ></i> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="signature-modal-body text-center" v-else> | ||||
|           <p>{{ $t("you_are_going_to_sign") }}</p> | ||||
|           <p>{{ $t("are_you_sure") }}</p> | ||||
|         </div> | ||||
|       </template> | ||||
|       <template v-slot:footer> | ||||
|         <button class="btn btn-action" @click.prevent="confirmSign"> | ||||
|           {{ $t("yes") }} | ||||
|         </button> | ||||
|       </template> | ||||
|     </modal> | ||||
|   </teleport> | ||||
|   <div class="col-12"> | ||||
|     <div | ||||
|       class="row justify-content-center mb-2" | ||||
|       v-if="signature.zones.length > 1" | ||||
|     > | ||||
|       <div class="col-4 gap-2 d-grid"> | ||||
|         <button | ||||
|           :disabled="userSignatureZone === null || userSignatureZone?.index < 1" | ||||
|           class="btn btn-light btn-sm" | ||||
|           @click="turnSignature(-1)" | ||||
|         > | ||||
|           {{ $t("last_sign_zone") }} | ||||
|         </button> | ||||
|       </div> | ||||
|       <div class="col-4 gap-2 d-grid"> | ||||
|         <button | ||||
|           :disabled="userSignatureZone?.index >= signature.zones.length - 1" | ||||
|           class="btn btn-light btn-sm" | ||||
|           @click="turnSignature(1)" | ||||
|         > | ||||
|           {{ $t("next_sign_zone") }} | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|       id="turn-page" | ||||
|       class="row justify-content-center mb-2" | ||||
|       v-if="pageCount > 1" | ||||
|     > | ||||
|       <div class="col-6-sm col-3-md text-center"> | ||||
|         <button | ||||
|           class="btn btn-light btn-sm" | ||||
|           :disabled="page <= 1" | ||||
|           @click="turnPage(-1)" | ||||
|         > | ||||
|           ❮ | ||||
|         </button> | ||||
|         <span>page {{ page }} / {{ pageCount }}</span> | ||||
|         <button | ||||
|           class="btn btn-light btn-sm" | ||||
|           :disabled="page >= pageCount" | ||||
|           @click="turnPage(1)" | ||||
|         > | ||||
|           ❯ | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div v-if="multiPage" class="col-12 text-center"> | ||||
|     <canvas | ||||
|       v-for="p in pageCount" | ||||
|       :key="p" | ||||
|       class="m-auto" | ||||
|       :id="`canvas-${p}`" | ||||
|     ></canvas> | ||||
|   </div> | ||||
|   <div v-else class="col-12 text-center"> | ||||
|     <canvas class="m-auto" :id="canvas"></canvas> | ||||
|   </div> | ||||
|  | ||||
|   <div class="col-12 p-4" id="action-buttons" v-if="signedState !== 'signed'"> | ||||
|     <div class="row mb-3"> | ||||
|       <div class="col-12 d-flex justify-content-end"> | ||||
|         <div class="col-4 col-xl-3 gap-2 d-grid"> | ||||
|           <button | ||||
|             v-if="adding" | ||||
|             class="btn btn-misc btn-cancel me-2 btn-sm" | ||||
|             @click="removeNewZone()" | ||||
|           > | ||||
|             {{ $t("remove_sign_zone") }} | ||||
|           </button> | ||||
|         </div> | ||||
|         <div class="col-4 gap-2 d-grid"> | ||||
|           <button | ||||
|             class="btn btn-create btn-sm" | ||||
|             :class="{ active: canvasEvent === 'add' }" | ||||
|             @click="toggleAddZone()" | ||||
|           > | ||||
|             {{ $t("add_sign_zone") }} | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="row"> | ||||
|       <div class="col-4"> | ||||
|         <button | ||||
|           class="btn btn-action me-2" | ||||
|           :disabled="!userSignatureZone" | ||||
|           @click="sign" | ||||
|         > | ||||
|           {{ $t("sign") }} | ||||
|         </button> | ||||
|       </div> | ||||
|       <div class="col-8 d-flex justify-content-end"> | ||||
|         <button | ||||
|           class="btn btn-misc me-2" | ||||
|           :hidden="!userSignatureZone" | ||||
|           @click="undoSign" | ||||
|           v-if="signature.zones.length > 1" | ||||
|         > | ||||
|           {{ $t("choose_another_signature") }} | ||||
|         </button> | ||||
|         <button | ||||
|           class="btn btn-misc me-2" | ||||
|           :hidden="!userSignatureZone" | ||||
|           @click="undoSign" | ||||
|           v-else | ||||
|         > | ||||
|           {{ $t("cancel") }} | ||||
|         </button> | ||||
|         <button class="btn btn-delete" @click="undoSign"> | ||||
|           {{ $t("cancel_signing") }} | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, Ref, reactive } from "vue"; | ||||
| import { useToast } from "vue-toast-notification"; | ||||
| import "vue-toast-notification/dist/theme-sugar.css"; | ||||
| import { | ||||
|   CanvasEvent, | ||||
|   Signature, | ||||
|   SignatureZone, | ||||
|   SignedState, | ||||
| } from "../../types"; | ||||
| import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; | ||||
| import * as pdfjsLib from "pdfjs-dist"; | ||||
| import { | ||||
|   PDFDocumentProxy, | ||||
|   PDFPageProxy, | ||||
| } from "pdfjs-dist/types/src/display/api"; | ||||
|  | ||||
| // @ts-ignore | ||||
| import * as PdfWorker from "pdfjs-dist/build/pdf.worker.mjs"; | ||||
| console.log(PdfWorker); // incredible but this is needed | ||||
|  | ||||
| // import { PdfWorker } from 'pdfjs-dist/build/pdf.worker.mjs' | ||||
| // pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker; | ||||
|  | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import { | ||||
|   build_download_info_link, | ||||
|   download_and_decrypt_doc, | ||||
| } from "../StoredObjectButton/helpers"; | ||||
|  | ||||
| pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs"; | ||||
|  | ||||
| const multiPage: Ref<boolean> = ref(true); | ||||
| const modalOpen: Ref<boolean> = ref(false); | ||||
| const loading: Ref<boolean> = ref(false); | ||||
| const adding: Ref<boolean> = ref(false); | ||||
| const canvasEvent: Ref<CanvasEvent> = ref("select"); | ||||
| const signedState: Ref<SignedState> = ref("pending"); | ||||
| const page: Ref<number> = ref(1); | ||||
| const pageCount: Ref<number> = ref(0); | ||||
| let userSignatureZone: Ref<null | SignatureZone> = ref(null); | ||||
| let pdf = {} as PDFDocumentProxy; | ||||
|  | ||||
| declare global { | ||||
|   interface Window { | ||||
|     signature: Signature; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const $toast = useToast(); | ||||
|  | ||||
| const signature = window.signature; | ||||
| const urlInfo = build_download_info_link(signature.storedObject.filename); | ||||
|  | ||||
| console.log(signature); | ||||
|  | ||||
| const mountPdf = async (url: string) => { | ||||
|   const loadingTask = pdfjsLib.getDocument(url); | ||||
|   pdf = await loadingTask.promise; | ||||
|   pageCount.value = pdf.numPages; | ||||
|   if (multiPage.value) { | ||||
|     await setAllPages(); | ||||
|   } else { | ||||
|     await setPage(1); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const getRenderContext = (pdfPage: PDFPageProxy) => { | ||||
|   const scale = 1; | ||||
|   const viewport = pdfPage.getViewport({ scale }); | ||||
|   let canvas; | ||||
|   if (multiPage.value) { | ||||
|     canvas = document.getElementById( | ||||
|       `canvas-${pdfPage.pageNumber}` | ||||
|     ) as HTMLCanvasElement; | ||||
|   } else { | ||||
|     canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement; | ||||
|   } | ||||
|   const context = canvas.getContext("2d") as CanvasRenderingContext2D; | ||||
|   canvas.height = viewport.height; | ||||
|   canvas.width = viewport.width; | ||||
|  | ||||
|   return { | ||||
|     canvasContext: context, | ||||
|     viewport: viewport, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const setAllPages = async () => | ||||
|   Array.from(Array(pageCount.value).keys()).map((p) => setPage(p + 1)); | ||||
|  | ||||
| const setPage = async (page: number) => { | ||||
|   const pdfPage = await pdf.getPage(page); | ||||
|   const renderContext = getRenderContext(pdfPage); | ||||
|   await pdfPage.render(renderContext); | ||||
| }; | ||||
|  | ||||
| async function downloadAndOpen(): Promise<Blob> { | ||||
|   let raw; | ||||
|   try { | ||||
|     raw = await download_and_decrypt_doc( | ||||
|       urlInfo, | ||||
|       signature.storedObject.keyInfos, | ||||
|       new Uint8Array(signature.storedObject.iv) | ||||
|     ); | ||||
|   } catch (e) { | ||||
|     console.error("error while downloading and decrypting document", e); | ||||
|     throw e; | ||||
|   } | ||||
|   await mountPdf(URL.createObjectURL(raw)); | ||||
|   initPdf(); | ||||
|   return raw; | ||||
| } | ||||
|  | ||||
| const initPdf = () => { | ||||
|   const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement; | ||||
|   canvas.addEventListener("pointerup", canvasClick, false); | ||||
|   setTimeout(() => addZones(page.value), 800); | ||||
| }; | ||||
|  | ||||
| const scaleXToCanvas = (x: number, canvasWidth: number, PDFWidth: number) => | ||||
|   Math.round((x * canvasWidth) / PDFWidth); | ||||
|  | ||||
| const scaleYToCanvas = (h: number, canvasHeight: number, PDFHeight: number) => | ||||
|   Math.round((h * canvasHeight) / PDFHeight); | ||||
|  | ||||
| const hitSignature = ( | ||||
|   zone: SignatureZone, | ||||
|   xy: number[], | ||||
|   canvasWidth: number, | ||||
|   canvasHeight: number | ||||
| ) => | ||||
|   scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) < xy[0] && | ||||
|   xy[0] < | ||||
|     scaleXToCanvas(zone.x + zone.width, canvasWidth, zone.PDFPage.width) && | ||||
|   zone.PDFPage.height - | ||||
|     scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) < | ||||
|     xy[1] && | ||||
|   xy[1] < | ||||
|     scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) + | ||||
|       zone.PDFPage.height; | ||||
|  | ||||
| const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => { | ||||
|   userSignatureZone.value = z; | ||||
|   const ctx = canvas.getContext("2d"); | ||||
|   if (ctx) { | ||||
|     setPage(page.value); | ||||
|     setTimeout(() => drawZone(z, ctx, canvas.width, canvas.height), 200); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const selectZoneOnCanvas = (e: PointerEvent, canvas: HTMLCanvasElement) => | ||||
|   signature.zones | ||||
|     .filter((z) => z.PDFPage.index + 1 === page.value) | ||||
|     .map((z) => { | ||||
|       if ( | ||||
|         hitSignature(z, [e.offsetX, e.offsetY], canvas.width, canvas.height) | ||||
|       ) { | ||||
|         if (userSignatureZone.value === null) { | ||||
|           selectZone(z, canvas); | ||||
|         } else { | ||||
|           if (userSignatureZone.value.index === z.index) { | ||||
|             sign(); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|  | ||||
| const canvasClick = (e: PointerEvent) => { | ||||
|   const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement; | ||||
|   canvasEvent.value === "select" | ||||
|     ? selectZoneOnCanvas(e, canvas) | ||||
|     : addZoneOnCanvas(e, canvas); | ||||
| }; | ||||
|  | ||||
| const turnPage = async (upOrDown: number) => { | ||||
|   userSignatureZone.value = null; | ||||
|   page.value = page.value + upOrDown; | ||||
|   await setPage(page.value); | ||||
|   setTimeout(() => addZones(page.value), 200); | ||||
| }; | ||||
|  | ||||
| const turnSignature = async (upOrDown: number) => { | ||||
|   let zoneIndex = userSignatureZone.value?.index ?? -1; | ||||
|   if (zoneIndex < -1) { | ||||
|     zoneIndex = -1; | ||||
|   } | ||||
|   if (zoneIndex < signature.zones.length) { | ||||
|     zoneIndex = zoneIndex + upOrDown; | ||||
|   } else { | ||||
|     zoneIndex = 0; | ||||
|   } | ||||
|   let currentZone = signature.zones[zoneIndex]; | ||||
|   if (currentZone) { | ||||
|     page.value = currentZone.PDFPage.index + 1; | ||||
|     userSignatureZone.value = currentZone; | ||||
|     const canvas = document.querySelectorAll("canvas")[0]; | ||||
|     selectZone(currentZone, canvas); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const drawZone = ( | ||||
|   zone: SignatureZone, | ||||
|   ctx: CanvasRenderingContext2D, | ||||
|   canvasWidth: number, | ||||
|   canvasHeight: number | ||||
| ) => { | ||||
|   const unselectedBlue = "#007bff"; | ||||
|   const selectedBlue = "#034286"; | ||||
|   ctx.strokeStyle = | ||||
|     userSignatureZone.value?.index === zone.index | ||||
|       ? selectedBlue | ||||
|       : unselectedBlue; | ||||
|   ctx.lineWidth = 2; | ||||
|   ctx.lineJoin = "bevel"; | ||||
|   ctx.strokeRect( | ||||
|     scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width), | ||||
|     zone.PDFPage.height - | ||||
|       scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height), | ||||
|     scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width), | ||||
|     scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height) | ||||
|   ); | ||||
|   ctx.font = "bold 16px serif"; | ||||
|   ctx.textAlign = "center"; | ||||
|   ctx.fillStyle = "black"; | ||||
|   const xText = | ||||
|     scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) + | ||||
|     scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width) / 2; | ||||
|   const yText = | ||||
|     zone.PDFPage.height - | ||||
|     scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) + | ||||
|     scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height) / 2; | ||||
|   if (userSignatureZone.value?.index === zone.index) { | ||||
|     ctx.fillStyle = selectedBlue; | ||||
|     ctx.fillText("Signer ici", xText, yText); | ||||
|   } else { | ||||
|     ctx.fillStyle = unselectedBlue; | ||||
|     ctx.fillText("Choisir cette", xText, yText - 12); | ||||
|     ctx.fillText("zone de signature", xText, yText + 12); | ||||
|     // ctx.strokeStyle = "#c6c6c6"; // halo | ||||
|     // ctx.strokeText("Choisir cette", xText, yText - 12); | ||||
|     // ctx.strokeText("zone de signature", xText, yText + 12); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const addZones = (page: number) => { | ||||
|   const canvas = document.querySelectorAll("canvas")[0]; | ||||
|   const ctx = canvas.getContext("2d"); | ||||
|   if (ctx) { | ||||
|     signature.zones | ||||
|       .filter((z) => z.PDFPage.index + 1 === page) | ||||
|       .map((z) => drawZone(z, ctx, canvas.width, canvas.height)); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const checkSignature = () => { | ||||
|   const url = `/api/1.0/document/workflow/${signature.id}/check-signature`; | ||||
|   return makeFetch("GET", url) | ||||
|     .then((r) => { | ||||
|       signedState.value = r as SignedState; | ||||
|       checkForReady(); | ||||
|     }) | ||||
|     .catch((error) => { | ||||
|       signedState.value = "error"; | ||||
|       console.log("Error while checking the signature", error); | ||||
|       $toast.error( | ||||
|         `Erreur lors de la vérification de la signature: ${error.txt}` | ||||
|       ); | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const maxTryForReady = 60; //2 minutes for trying to sign | ||||
| let tryForReady = 0; | ||||
|  | ||||
| const stopTrySigning = () => { | ||||
|   loading.value = false; | ||||
|   modalOpen.value = false; | ||||
| }; | ||||
|  | ||||
| const checkForReady = () => { | ||||
|   if (tryForReady > maxTryForReady) { | ||||
|     stopTrySigning(); | ||||
|     tryForReady = 0; | ||||
|     console.log("Reached the maximum number of tentative to try signing"); | ||||
|     $toast.error( | ||||
|       "Le nombre maximum de tentatives pour essayer de signer est atteint" | ||||
|     ); | ||||
|   } | ||||
|   if (signedState.value === "rejected") { | ||||
|     stopTrySigning(); | ||||
|     console.log("Signature rejected by the server"); | ||||
|     $toast.error("Signature rejetée par le serveur"); | ||||
|   } | ||||
|   if (signedState.value === "canceled") { | ||||
|     stopTrySigning(); | ||||
|     console.log("Signature canceled"); | ||||
|     $toast.error("Signature annulée"); | ||||
|   } | ||||
|   if (signedState.value === "pending") { | ||||
|     tryForReady = tryForReady + 1; | ||||
|     setTimeout(() => checkSignature(), 2000); | ||||
|   } else { | ||||
|     stopTrySigning(); | ||||
|     if (signedState.value === "signed") { | ||||
|       userSignatureZone.value = null; | ||||
|       downloadAndOpen(); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const sign = () => (modalOpen.value = true); | ||||
|  | ||||
| const confirmSign = () => { | ||||
|   loading.value = true; | ||||
|   const url = `/api/1.0/document/workflow/${signature.id}/signature-request`; | ||||
|   const body = { | ||||
|     storedObject: signature.storedObject, | ||||
|     zone: userSignatureZone.value, | ||||
|   }; | ||||
|   makeFetch("POST", url, body) | ||||
|     .then((r) => { | ||||
|       checkForReady(); | ||||
|     }) | ||||
|     .catch((error) => { | ||||
|       console.log("Error while posting the signature", error); | ||||
|       stopTrySigning(); | ||||
|       $toast.error( | ||||
|         `Erreur lors de la soumission de la signature: ${error.txt}` | ||||
|       ); | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const undoSign = async () => { | ||||
|   signature.zones = signature.zones.filter((z) => z.index !== null); | ||||
|   await setPage(page.value); | ||||
|   setTimeout(() => addZones(page.value), 200); | ||||
|   userSignatureZone.value = null; | ||||
|   adding.value = false; | ||||
| }; | ||||
|  | ||||
| const toggleAddZone = () => { | ||||
|   canvasEvent.value === "select" | ||||
|     ? (canvasEvent.value = "add") | ||||
|     : (canvasEvent.value = "select"); | ||||
| }; | ||||
|  | ||||
| const addZoneOnCanvas = (e: PointerEvent, canvas: HTMLCanvasElement) => { | ||||
|   const BOX_WIDTH = 180; | ||||
|   const BOX_HEIGHT = 90; | ||||
|   const PDFPageHeight = canvas.height; | ||||
|   const PDFPageWidth = canvas.width; | ||||
|  | ||||
|   const x = e.offsetX; | ||||
|   const y = e.offsetY; | ||||
|   const newZone: SignatureZone = { | ||||
|     index: null, | ||||
|     x: | ||||
|       scaleXToCanvas(x, canvas.width, PDFPageWidth) - | ||||
|       scaleXToCanvas(BOX_WIDTH / 2, canvas.width, PDFPageWidth), | ||||
|     y: | ||||
|       PDFPageHeight - | ||||
|       scaleYToCanvas(y, canvas.height, PDFPageHeight) + | ||||
|       scaleYToCanvas(BOX_HEIGHT / 2, canvas.height, PDFPageHeight), | ||||
|     width: BOX_WIDTH, | ||||
|     height: BOX_HEIGHT, | ||||
|     PDFPage: { | ||||
|       index: page.value - 1, | ||||
|       width: PDFPageWidth, | ||||
|       height: PDFPageHeight, | ||||
|     }, | ||||
|   }; | ||||
|   signature.zones.push(newZone); | ||||
|  | ||||
|   setTimeout(() => addZones(page.value), 200); | ||||
|   canvasEvent.value = "select"; | ||||
|   adding.value = true; | ||||
| }; | ||||
|  | ||||
| const removeNewZone = async () => { | ||||
|   signature.zones = signature.zones.filter((z) => z.index !== null); | ||||
|   userSignatureZone.value = null; | ||||
|   await setPage(page.value); | ||||
|   setTimeout(() => addZones(page.value), 200); | ||||
|   canvasEvent.value = "select"; | ||||
|   adding.value = false; | ||||
| }; | ||||
|  | ||||
| downloadAndOpen(); | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| canvas { | ||||
|   box-shadow: 0 20px 20px rgba(0, 0, 0, 0.1); | ||||
| } | ||||
| div#action-buttons { | ||||
|   position: sticky; | ||||
|   bottom: 0px; | ||||
|   background-color: white; | ||||
|   z-index: 100; | ||||
| } | ||||
| div#turn-page { | ||||
|   span { | ||||
|     font-size: 0.8rem; | ||||
|     margin: 0 0.4rem; | ||||
|   } | ||||
| } | ||||
| div.signature-modal-body { | ||||
|   height: 8rem; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| @@ -0,0 +1,32 @@ | ||||
| import { createApp } from "vue"; | ||||
| // @ts-ignore | ||||
| import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n"; | ||||
| import App from "./App.vue"; | ||||
|  | ||||
| const appMessages = { | ||||
|     fr: { | ||||
|         yes: 'Oui', | ||||
|         are_you_sure: 'Êtes-vous sûr·e?', | ||||
|         you_are_going_to_sign: 'Vous allez signer le document', | ||||
|         signature_confirmation: 'Confirmation de la signature', | ||||
|         sign: 'Signer', | ||||
|         choose_another_signature: 'Choisir une autre zone de signature', | ||||
|         cancel: 'Annuler', | ||||
|         cancel_signing: 'Refuser de signer', | ||||
|         last_sign_zone: 'Zone de signature précédente', | ||||
|         next_sign_zone: 'Zone de signature suivante', | ||||
|         electronic_signature_in_progress: 'Signature électronique en cours...', | ||||
|         loading: 'Chargement...', | ||||
|         add_sign_zone: 'Ajouter une zone de signature', | ||||
|         remove_sign_zone: 'Enlever la zone', | ||||
|     } | ||||
| } | ||||
|  | ||||
| const i18n = _createI18n(appMessages); | ||||
|  | ||||
| const app = createApp({ | ||||
|     template: `<app></app>`, | ||||
| }) | ||||
|     .use(i18n) | ||||
|     .component("app", App) | ||||
|     .mount("#document-signature"); | ||||
| @@ -71,7 +71,7 @@ | ||||
|                 </li> | ||||
|                 {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %} | ||||
|                     <li> | ||||
|                         {{ document.object|chill_document_button_group(document.title, is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document)) }} | ||||
|                         {{ document.object|chill_document_button_group(document.title) }} | ||||
|                     </li> | ||||
|                     <li> | ||||
|                         <a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a> | ||||
| @@ -90,7 +90,7 @@ | ||||
|             {% else %} | ||||
|                 {% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %} | ||||
|                     <li> | ||||
|                         {{ document.object|chill_document_button_group(document.title, is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document)) }} | ||||
|                         {{ document.object|chill_document_button_group(document.title) }} | ||||
|                     </li> | ||||
|                     <li> | ||||
|                         <a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a> | ||||
|   | ||||
| @@ -0,0 +1,38 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="fr"> | ||||
| <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <meta http-equiv="x-ua-compatible" content="ie=edge"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <link rel="shortcut icon" href="{{ asset('build/images/favicon.ico') }}" type="image/x-icon"> | ||||
|     <title>Signature</title> | ||||
|  | ||||
|     {{ encore_entry_link_tags('mod_bootstrap') }} | ||||
|     {{ encore_entry_link_tags('mod_forkawesome') }} | ||||
|     {{ encore_entry_link_tags('chill') }} | ||||
|     {{ encore_entry_link_tags('vue_document_signature') }} | ||||
|  | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|  | ||||
|     {% block js %} | ||||
|         {{ encore_entry_script_tags('mod_document_action_buttons_group') }} | ||||
|         <script type="text/javascript"> | ||||
|             window.signature = {{ signature|json_encode|raw }}; | ||||
|         </script> | ||||
|         {{ encore_entry_script_tags('vue_document_signature') }} | ||||
|     {% endblock %} | ||||
|  | ||||
|     <div class="content" id="content"> | ||||
|         <div class="container-xxl"> | ||||
|             <div class="row"> | ||||
|                <div class="col-xs-12 col-md-12 col-lg-9 my-5 m-auto"> | ||||
|                    <h4>{{ 'Document %title%' | trans({ '%title%': document.title }) }}</h4> | ||||
|                    <div class="row" id="document-signature"></div> | ||||
|                </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </body> | ||||
| </html> | ||||
| @@ -70,12 +70,12 @@ class AccompanyingCourseDocumentVoter extends AbstractChillVoter implements Prov | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     protected function supports($attribute, $subject): bool | ||||
|     public function supports($attribute, $subject): bool | ||||
|     { | ||||
|         return $this->voterHelper->supports($attribute, $subject); | ||||
|     } | ||||
|  | ||||
|     protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool | ||||
|     public function voteOnAttribute($attribute, $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         if (!$token->getUser() instanceof User) { | ||||
|             return false; | ||||
|   | ||||
| @@ -12,6 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\AsyncUpload\SignedUrl; | ||||
| use Chill\DocStoreBundle\Repository\StoredObjectRepository; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| @@ -22,6 +23,7 @@ final class AsyncUploadVoter extends Voter | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly Security $security, | ||||
|         private readonly StoredObjectRepository $storedObjectRepository | ||||
|     ) {} | ||||
|  | ||||
|     protected function supports($attribute, $subject): bool | ||||
| @@ -32,10 +34,16 @@ final class AsyncUploadVoter extends Voter | ||||
|     protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         /** @var SignedUrl $subject */ | ||||
|         if (!in_array($subject->method, ['POST', 'GET', 'HEAD'], true)) { | ||||
|         if (!in_array($subject->method, ['POST', 'GET', 'HEAD', 'PUT'], true)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'); | ||||
|         $storedObject = $this->storedObjectRepository->findOneBy(['filename' => $subject->object_name]); | ||||
|  | ||||
|         return match ($subject->method) { | ||||
|             'GET', 'HEAD' => $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject), | ||||
|             'PUT' => $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject), | ||||
|             'POST' => $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN') | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,9 +12,10 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * Voter for the content of a stored object. | ||||
| @@ -23,6 +24,10 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
|  */ | ||||
| class StoredObjectVoter extends Voter | ||||
| { | ||||
|     public const LOG_PREFIX = '[stored object voter] '; | ||||
|  | ||||
|     public function __construct(private readonly Security $security, private readonly iterable $storedObjectVoters, private readonly LoggerInterface $logger) {} | ||||
|  | ||||
|     protected function supports($attribute, $subject): bool | ||||
|     { | ||||
|         return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum | ||||
| @@ -32,24 +37,28 @@ class StoredObjectVoter extends Voter | ||||
|     protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         /** @var StoredObject $subject */ | ||||
|         if ( | ||||
|             !$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) | ||||
|             || $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) | ||||
|         ) { | ||||
|             return false; | ||||
|         $attributeAsEnum = StoredObjectRoleEnum::from($attribute); | ||||
|  | ||||
|         // Loop through context-specific voters | ||||
|         foreach ($this->storedObjectVoters as $storedObjectVoter) { | ||||
|             if ($storedObjectVoter->supports($attributeAsEnum, $subject)) { | ||||
|                 $grant = $storedObjectVoter->voteOnAttribute($attributeAsEnum, $subject, $token); | ||||
|  | ||||
|                 if (false === $grant) { | ||||
|                     $this->logger->debug(self::LOG_PREFIX.'deny access by storedObjectVoter', ['stored_object_voter' => $storedObjectVoter::class]); | ||||
|                 } | ||||
|  | ||||
|                 return $grant; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) { | ||||
|             return false; | ||||
|         // User role-based fallback | ||||
|         if ($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN')) { | ||||
|             // TODO: this maybe considered as a security issue, as all authenticated users can reach a stored object which | ||||
|             // is potentially detached from an existing entity. | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         $askedRole = StoredObjectRoleEnum::from($attribute); | ||||
|         $tokenRoleAuthorization = | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS); | ||||
|  | ||||
|         return match ($askedRole) { | ||||
|             StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization, | ||||
|             StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization | ||||
|         }; | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,69 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface | ||||
| { | ||||
|     abstract protected function getRepository(): AssociatedEntityToStoredObjectInterface; | ||||
|  | ||||
|     /** | ||||
|      * @return class-string | ||||
|      */ | ||||
|     abstract protected function getClass(): string; | ||||
|  | ||||
|     abstract protected function attributeToRole(StoredObjectRoleEnum $attribute): string; | ||||
|  | ||||
|     abstract protected function canBeAssociatedWithWorkflow(): bool; | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly Security $security, | ||||
|         private readonly ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null, | ||||
|     ) {} | ||||
|  | ||||
|     public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool | ||||
|     { | ||||
|         $class = $this->getClass(); | ||||
|  | ||||
|         return $this->getRepository()->findAssociatedEntityToStoredObject($subject) instanceof $class; | ||||
|     } | ||||
|  | ||||
|     public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         // Retrieve the related accompanying course document | ||||
|         $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); | ||||
|  | ||||
|         // Determine the attribute to pass to AccompanyingCourseDocumentVoter | ||||
|         $voterAttribute = $this->attributeToRole($attribute); | ||||
|  | ||||
|         if (false === $this->security->isGranted($voterAttribute, $entity)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (StoredObjectRoleEnum::SEE !== $attribute && $this->canBeAssociatedWithWorkflow()) { | ||||
|             if (null === $this->workflowDocumentService) { | ||||
|                 throw new \LogicException('Provide a workflow document service'); | ||||
|             } | ||||
|  | ||||
|             return $this->workflowDocumentService->notBlockedByWorkflow($entity); | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,54 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; | ||||
| use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredObjectVoter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly AccompanyingCourseDocumentRepository $repository, | ||||
|         Security $security, | ||||
|         WorkflowStoredObjectPermissionHelper $workflowDocumentService | ||||
|     ) { | ||||
|         parent::__construct($security, $workflowDocumentService); | ||||
|     } | ||||
|  | ||||
|     protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|     { | ||||
|         return $this->repository; | ||||
|     } | ||||
|  | ||||
|     protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|     { | ||||
|         return match ($attribute) { | ||||
|             StoredObjectRoleEnum::EDIT => AccompanyingCourseDocumentVoter::UPDATE, | ||||
|             StoredObjectRoleEnum::SEE => AccompanyingCourseDocumentVoter::SEE_DETAILS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     protected function getClass(): string | ||||
|     { | ||||
|         return AccompanyingCourseDocument::class; | ||||
|     } | ||||
|  | ||||
|     protected function canBeAssociatedWithWorkflow(): bool | ||||
|     { | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,54 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\PersonDocument; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Repository\PersonDocumentRepository; | ||||
| use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly PersonDocumentRepository $repository, | ||||
|         Security $security, | ||||
|         WorkflowStoredObjectPermissionHelper $workflowDocumentService | ||||
|     ) { | ||||
|         parent::__construct($security, $workflowDocumentService); | ||||
|     } | ||||
|  | ||||
|     protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|     { | ||||
|         return $this->repository; | ||||
|     } | ||||
|  | ||||
|     protected function getClass(): string | ||||
|     { | ||||
|         return PersonDocument::class; | ||||
|     } | ||||
|  | ||||
|     protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|     { | ||||
|         return match ($attribute) { | ||||
|             StoredObjectRoleEnum::EDIT => PersonDocumentVoter::UPDATE, | ||||
|             StoredObjectRoleEnum::SEE => PersonDocumentVoter::SEE_DETAILS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     protected function canBeAssociatedWithWorkflow(): bool | ||||
|     { | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
|  | ||||
| interface StoredObjectVoterInterface | ||||
| { | ||||
|     public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool; | ||||
|  | ||||
|     public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool; | ||||
| } | ||||
| @@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| @@ -32,7 +33,8 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider, | ||||
|         private readonly UrlGeneratorInterface $urlGenerator | ||||
|         private readonly UrlGeneratorInterface $urlGenerator, | ||||
|         private readonly Security $security | ||||
|     ) {} | ||||
|  | ||||
|     public function normalize($object, ?string $format = null, array $context = []) | ||||
| @@ -55,13 +57,13 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa | ||||
|         // deprecated property | ||||
|         $datas['creationDate'] = $datas['createdAt']; | ||||
|  | ||||
|         $canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true); | ||||
|         $canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true); | ||||
|         $canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object); | ||||
|         $canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object); | ||||
|  | ||||
|         if ($canDavSee || $canDavEdit) { | ||||
|         if ($canSee || $canEdit) { | ||||
|             $accessToken = $this->JWTDavTokenProvider->createToken( | ||||
|                 $object, | ||||
|                 $canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE | ||||
|                 $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE | ||||
|             ); | ||||
|  | ||||
|             $datas['_links'] = [ | ||||
|   | ||||
| @@ -0,0 +1,23 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| /** | ||||
|  * Message which is received when a pdf is signed. | ||||
|  */ | ||||
| final readonly class PdfSignedMessage | ||||
| { | ||||
|     public function __construct( | ||||
|         public readonly int $signatureId, | ||||
|         public readonly string $content | ||||
|     ) {} | ||||
| } | ||||
| @@ -0,0 +1,61 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Messenger\Handler\MessageHandlerInterface; | ||||
|  | ||||
| final readonly class PdfSignedMessageHandler implements MessageHandlerInterface | ||||
| { | ||||
|     /** | ||||
|      * log prefix. | ||||
|      */ | ||||
|     private const P = '[pdf signed message] '; | ||||
|  | ||||
|     public function __construct( | ||||
|         private LoggerInterface $logger, | ||||
|         private EntityWorkflowManager $entityWorkflowManager, | ||||
|         private StoredObjectManagerInterface $storedObjectManager, | ||||
|         private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository, | ||||
|         private EntityManagerInterface $entityManager, | ||||
|         private ClockInterface $clock, | ||||
|     ) {} | ||||
|  | ||||
|     public function __invoke(PdfSignedMessage $message): void | ||||
|     { | ||||
|         $this->logger->info(self::P.'a message is received', ['signaturedId' => $message->signatureId]); | ||||
|  | ||||
|         $signature = $this->entityWorkflowStepSignatureRepository->find($message->signatureId); | ||||
|  | ||||
|         if (null === $signature) { | ||||
|             throw new \RuntimeException('no signature found'); | ||||
|         } | ||||
|  | ||||
|         $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($signature->getStep()->getEntityWorkflow()); | ||||
|  | ||||
|         if (null === $storedObject) { | ||||
|             throw new \RuntimeException('no stored object found'); | ||||
|         } | ||||
|  | ||||
|         $this->storedObjectManager->write($storedObject, $message->content); | ||||
|  | ||||
|         $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now()); | ||||
|         $this->entityManager->flush(); | ||||
|         $this->entityManager->clear(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,66 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Messenger\Exception\MessageDecodingFailedException; | ||||
| use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; | ||||
|  | ||||
| /** | ||||
|  * Decode (and requeue) @see{PdfSignedMessage}, which comes from an external producer. | ||||
|  */ | ||||
| final readonly class PdfSignedMessageSerializer implements SerializerInterface | ||||
| { | ||||
|     public function decode(array $encodedEnvelope): Envelope | ||||
|     { | ||||
|         $body = $encodedEnvelope['body']; | ||||
|  | ||||
|         try { | ||||
|             $decoded = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR); | ||||
|         } catch (\JsonException $e) { | ||||
|             throw new MessageDecodingFailedException('Could not deserialize message', previous: $e); | ||||
|         } | ||||
|  | ||||
|         if (!array_key_exists('signatureId', $decoded) || !array_key_exists('content', $decoded)) { | ||||
|             throw new MessageDecodingFailedException('Could not find expected keys: signatureId or content'); | ||||
|         } | ||||
|  | ||||
|         $content = base64_decode((string) $decoded['content'], true); | ||||
|  | ||||
|         if (false === $content) { | ||||
|             throw new MessageDecodingFailedException('Invalid character found in the base64 encoded content'); | ||||
|         } | ||||
|  | ||||
|         $message = new PdfSignedMessage($decoded['signatureId'], $content); | ||||
|  | ||||
|         return new Envelope($message); | ||||
|     } | ||||
|  | ||||
|     public function encode(Envelope $envelope): array | ||||
|     { | ||||
|         $message = $envelope->getMessage(); | ||||
|  | ||||
|         if (!$message instanceof PdfSignedMessage) { | ||||
|             throw new MessageDecodingFailedException('Expected a PdfSignedMessage'); | ||||
|         } | ||||
|  | ||||
|         $data = [ | ||||
|             'signatureId' => $message->signatureId, | ||||
|             'content' => base64_encode($message->content), | ||||
|         ]; | ||||
|  | ||||
|         return [ | ||||
|             'body' => json_encode($data, JSON_THROW_ON_ERROR), | ||||
|             'headers' => [], | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; | ||||
|  | ||||
| /** | ||||
|  * Message which is sent when we request a signature on a pdf. | ||||
|  */ | ||||
| final readonly class RequestPdfSignMessage | ||||
| { | ||||
|     public function __construct( | ||||
|         public int $signatureId, | ||||
|         public PDFSignatureZone $PDFSignatureZone, | ||||
|         public int $signatureZoneIndex, | ||||
|         public string $reason, | ||||
|         public string $signerText, | ||||
|         public string $content, | ||||
|     ) {} | ||||
| } | ||||
| @@ -0,0 +1,105 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Messenger\Exception\MessageDecodingFailedException; | ||||
| use Symfony\Component\Messenger\Stamp\NonSendableStampInterface; | ||||
| use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
|  | ||||
| /** | ||||
|  * Serialize a RequestPdfSignMessage, for external consumer. | ||||
|  */ | ||||
| final readonly class RequestPdfSignMessageSerializer implements SerializerInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private NormalizerInterface $normalizer, | ||||
|         private DenormalizerInterface $denormalizer, | ||||
|     ) {} | ||||
|  | ||||
|     public function decode(array $encodedEnvelope): Envelope | ||||
|     { | ||||
|         $body = $encodedEnvelope['body']; | ||||
|         $headers = $encodedEnvelope['headers']; | ||||
|  | ||||
|         if (RequestPdfSignMessage::class !== ($headers['Message'] ?? null)) { | ||||
|             throw new MessageDecodingFailedException('serializer does not support this message'); | ||||
|         } | ||||
|  | ||||
|         $data = json_decode((string) $body, true); | ||||
|  | ||||
|         $zoneSignature = $this->denormalizer->denormalize($data['signatureZone'], PDFSignatureZone::class, 'json', [ | ||||
|             AbstractNormalizer::GROUPS => ['write'], | ||||
|         ]); | ||||
|  | ||||
|         $content = base64_decode((string) $data['content'], true); | ||||
|  | ||||
|         if (false === $content) { | ||||
|             throw new MessageDecodingFailedException('the content could not be converted from base64 encoding'); | ||||
|         } | ||||
|  | ||||
|         $message = new RequestPdfSignMessage( | ||||
|             $data['signatureId'], | ||||
|             $zoneSignature, | ||||
|             $data['signatureZoneIndex'], | ||||
|             $data['reason'], | ||||
|             $data['signerText'], | ||||
|             $content, | ||||
|         ); | ||||
|  | ||||
|         // in case of redelivery, unserialize any stamps | ||||
|         $stamps = []; | ||||
|         if (isset($headers['stamps'])) { | ||||
|             $stamps = unserialize($headers['stamps']); | ||||
|         } | ||||
|  | ||||
|         return new Envelope($message, $stamps); | ||||
|     } | ||||
|  | ||||
|     public function encode(Envelope $envelope): array | ||||
|     { | ||||
|         $message = $envelope->getMessage(); | ||||
|  | ||||
|         if (!$message instanceof RequestPdfSignMessage) { | ||||
|             throw new MessageDecodingFailedException('Message is not a RequestPdfSignMessage'); | ||||
|         } | ||||
|  | ||||
|         $data = [ | ||||
|             'signatureId' => $message->signatureId, | ||||
|             'signatureZoneIndex' => $message->signatureZoneIndex, | ||||
|             'signatureZone' => $this->normalizer->normalize($message->PDFSignatureZone, 'json', [AbstractNormalizer::GROUPS => ['read']]), | ||||
|             'reason' => $message->reason, | ||||
|             'signerText' => $message->signerText, | ||||
|             'content' => base64_encode($message->content), | ||||
|         ]; | ||||
|  | ||||
|         $allStamps = []; | ||||
|         foreach ($envelope->all() as $stamp) { | ||||
|             if ($stamp instanceof NonSendableStampInterface) { | ||||
|                 continue; | ||||
|             } | ||||
|             $allStamps = [...$allStamps, ...$stamp]; | ||||
|         } | ||||
|  | ||||
|         return [ | ||||
|             'body' => json_encode($data, JSON_THROW_ON_ERROR, 512), | ||||
|             'headers' => [ | ||||
|                 'stamps' => serialize($allStamps), | ||||
|                 'Message' => RequestPdfSignMessage::class, | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										33
									
								
								src/Bundle/ChillDocStoreBundle/Service/Signature/PDFPage.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/Bundle/ChillDocStoreBundle/Service/Signature/PDFPage.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service\Signature; | ||||
|  | ||||
| use Symfony\Component\Serializer\Annotation\Groups; | ||||
|  | ||||
| final readonly class PDFPage | ||||
| { | ||||
|     public function __construct( | ||||
|         #[Groups(['read'])] | ||||
|         public int $index, | ||||
|         #[Groups(['read'])] | ||||
|         public float $width, | ||||
|         #[Groups(['read'])] | ||||
|         public float $height, | ||||
|     ) {} | ||||
|  | ||||
|     public function equals(self $page): bool | ||||
|     { | ||||
|         return $page->index === $this->index | ||||
|             && round($page->width, 2) === round($this->width, 2) | ||||
|             && round($page->height, 2) === round($this->height, 2); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,43 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service\Signature; | ||||
|  | ||||
| use Symfony\Component\Serializer\Annotation\Groups; | ||||
|  | ||||
| final readonly class PDFSignatureZone | ||||
| { | ||||
|     public function __construct( | ||||
|         #[Groups(['read'])] | ||||
|         public int $index, | ||||
|         #[Groups(['read'])] | ||||
|         public float $x, | ||||
|         #[Groups(['read'])] | ||||
|         public float $y, | ||||
|         #[Groups(['read'])] | ||||
|         public float $height, | ||||
|         #[Groups(['read'])] | ||||
|         public float $width, | ||||
|         #[Groups(['read'])] | ||||
|         public PDFPage $PDFPage, | ||||
|     ) {} | ||||
|  | ||||
|     public function equals(self $other): bool | ||||
|     { | ||||
|         return | ||||
|             $this->index == $other->index | ||||
|             && $this->x == $other->x | ||||
|             && $this->y == $other->y | ||||
|             && $this->height == $other->height | ||||
|             && $this->width == $other->width | ||||
|             && $this->PDFPage->equals($other->PDFPage); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,60 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service\Signature; | ||||
|  | ||||
| use Smalot\PdfParser\Parser; | ||||
|  | ||||
| class PDFSignatureZoneParser | ||||
| { | ||||
|     public const ZONE_SIGNATURE_START = 'signature_zone'; | ||||
|  | ||||
|     private readonly Parser $parser; | ||||
|  | ||||
|     public function __construct( | ||||
|         public float $defaultHeight = 90.0, | ||||
|         public float $defaultWidth = 180.0, | ||||
|     ) { | ||||
|         $this->parser = new Parser(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return list<PDFSignatureZone> | ||||
|      */ | ||||
|     public function findSignatureZones(string $fileContent): array | ||||
|     { | ||||
|         $pdf = $this->parser->parseContent($fileContent); | ||||
|         $zones = []; | ||||
|  | ||||
|         $defaults = $pdf->getObjectsByType('Pages'); | ||||
|         $defaultPage = reset($defaults); | ||||
|         $defaultPageDetails = $defaultPage->getDetails(); | ||||
|         $zoneIndex = 0; | ||||
|  | ||||
|         foreach ($pdf->getPages() as $index => $page) { | ||||
|             $details = $page->getDetails(); | ||||
|             $pdfPage = new PDFPage( | ||||
|                 $index, | ||||
|                 (float) ($details['MediaBox'][2] ?? $defaultPageDetails['MediaBox'][2]), | ||||
|                 (float) ($details['MediaBox'][3] ?? $defaultPageDetails['MediaBox'][3]), | ||||
|             ); | ||||
|  | ||||
|             foreach ($page->getDataTm() as $dataTm) { | ||||
|                 if (str_starts_with((string) $dataTm[1], self::ZONE_SIGNATURE_START)) { | ||||
|                     $zones[] = new PDFSignatureZone($zoneIndex, (float) $dataTm[0][4], (float) $dataTm[0][5], $this->defaultHeight, $this->defaultWidth, $pdfPage); | ||||
|                     ++$zoneIndex; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $zones; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,38 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Service; | ||||
|  | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class WorkflowStoredObjectPermissionHelper | ||||
| { | ||||
|     public function __construct(private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager) {} | ||||
|  | ||||
|     public function notBlockedByWorkflow(object $entity): bool | ||||
|     { | ||||
|         $workflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|         $currentUser = $this->security->getUser(); | ||||
|  | ||||
|         foreach ($workflows as $workflow) { | ||||
|             if ($workflow->isFinal()) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (!$workflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| use Twig\Environment; | ||||
| @@ -128,6 +129,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt | ||||
|         private NormalizerInterface $normalizer, | ||||
|         private JWTDavTokenProviderInterface $davTokenProvider, | ||||
|         private UrlGeneratorInterface $urlGenerator, | ||||
|         private Security $security, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
| @@ -148,8 +150,10 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt | ||||
|      * @throws \Twig\Error\RuntimeError | ||||
|      * @throws \Twig\Error\SyntaxError | ||||
|      */ | ||||
|     public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string | ||||
|     public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $showEditButtons = true, array $options = []): string | ||||
|     { | ||||
|         $canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $document) && $showEditButtons; | ||||
|  | ||||
|         $accessToken = $this->davTokenProvider->createToken( | ||||
|             $document, | ||||
|             $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE | ||||
|   | ||||
| @@ -122,7 +122,8 @@ class TempUrlOpenstackGeneratorTest extends TestCase | ||||
|         $signedUrl = new SignedUrl( | ||||
|             'GET', | ||||
|             'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543', | ||||
|             \DateTimeImmutable::createFromFormat('U', '1702043543') | ||||
|             \DateTimeImmutable::createFromFormat('U', '1702043543'), | ||||
|             $objectName | ||||
|         ); | ||||
|  | ||||
|         foreach ($baseUrls as $baseUrl) { | ||||
| @@ -153,6 +154,7 @@ class TempUrlOpenstackGeneratorTest extends TestCase | ||||
|         $signedUrl = new SignedUrlPost( | ||||
|             'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543', | ||||
|             \DateTimeImmutable::createFromFormat('U', '1702043543'), | ||||
|             $objectName, | ||||
|             150, | ||||
|             1, | ||||
|             1800, | ||||
|   | ||||
| @@ -35,7 +35,7 @@ class AsyncUploadExtensionTest extends KernelTestCase | ||||
|     { | ||||
|         $generator = $this->prophesize(TempUrlGeneratorInterface::class); | ||||
|         $generator->generate(Argument::in(['GET', 'POST']), Argument::type('string'), Argument::any()) | ||||
|             ->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'))); | ||||
|             ->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'), $args[1])); | ||||
|  | ||||
|         $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); | ||||
|         $urlGenerator->generate('async_upload.generate_url', Argument::type('array')) | ||||
|   | ||||
| @@ -73,6 +73,7 @@ class AsyncUploadControllerTest extends TestCase | ||||
|                 return new SignedUrlPost( | ||||
|                     'https://object.store.example', | ||||
|                     new \DateTimeImmutable('1 hour'), | ||||
|                     'abc', | ||||
|                     150, | ||||
|                     1, | ||||
|                     1800, | ||||
| @@ -87,7 +88,8 @@ class AsyncUploadControllerTest extends TestCase | ||||
|                 return new SignedUrl( | ||||
|                     $method, | ||||
|                     'https://object.store.example', | ||||
|                     new \DateTimeImmutable('1 hour') | ||||
|                     new \DateTimeImmutable('1 hour'), | ||||
|                     $object_name | ||||
|                 ); | ||||
|             } | ||||
|         }; | ||||
|   | ||||
| @@ -23,6 +23,7 @@ use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Component\Form\PreloadedExtension; | ||||
| use Symfony\Component\Form\Test\TypeTestCase; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Encoder\JsonEncoder; | ||||
| use Symfony\Component\Serializer\Serializer; | ||||
|  | ||||
| @@ -80,11 +81,15 @@ class StoredObjectTypeTest extends TypeTestCase | ||||
|         $urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL) | ||||
|             ->willReturn('http://url/fake'); | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted(Argument::cetera())->willReturn(true); | ||||
|  | ||||
|         $serializer = new Serializer( | ||||
|             [ | ||||
|                 new StoredObjectNormalizer( | ||||
|                     $jwtTokenProvider->reveal(), | ||||
|                     $urlGenerator->reveal(), | ||||
|                     $security->reveal() | ||||
|                 ), | ||||
|             ], | ||||
|             [ | ||||
|   | ||||
| @@ -0,0 +1,168 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class AbstractStoredObjectVoterTest extends TestCase | ||||
| { | ||||
|     private AssociatedEntityToStoredObjectInterface $repository; | ||||
|     private Security $security; | ||||
|     private WorkflowStoredObjectPermissionHelper $workflowDocumentService; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         $this->repository = $this->createMock(AssociatedEntityToStoredObjectInterface::class); | ||||
|         $this->security = $this->createMock(Security::class); | ||||
|         $this->workflowDocumentService = $this->createMock(WorkflowStoredObjectPermissionHelper::class); | ||||
|     } | ||||
|  | ||||
|     private function buildStoredObjectVoter(bool $canBeAssociatedWithWorkflow, AssociatedEntityToStoredObjectInterface $repository, Security $security, ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null): AbstractStoredObjectVoter | ||||
|     { | ||||
|         // Anonymous class extending the abstract class | ||||
|         return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter { | ||||
|             public function __construct( | ||||
|                 private readonly bool $canBeAssociatedWithWorkflow, | ||||
|                 private readonly AssociatedEntityToStoredObjectInterface $repository, | ||||
|                 Security $security, | ||||
|                 ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null | ||||
|             ) { | ||||
|                 parent::__construct($security, $workflowDocumentService); | ||||
|             } | ||||
|  | ||||
|             protected function attributeToRole($attribute): string | ||||
|             { | ||||
|                 return 'SOME_ROLE'; | ||||
|             } | ||||
|  | ||||
|             protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|             { | ||||
|                 return $this->repository; | ||||
|             } | ||||
|  | ||||
|             protected function getClass(): string | ||||
|             { | ||||
|                 return \stdClass::class; | ||||
|             } | ||||
|  | ||||
|             protected function canBeAssociatedWithWorkflow(): bool | ||||
|             { | ||||
|                 return $this->canBeAssociatedWithWorkflow; | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private function setupMockObjects(): array | ||||
|     { | ||||
|         $user = new User(); | ||||
|         $token = $this->createMock(TokenInterface::class); | ||||
|         $subject = new StoredObject(); | ||||
|         $entity = new \stdClass(); | ||||
|  | ||||
|         return [$user, $token, $subject, $entity]; | ||||
|     } | ||||
|  | ||||
|     private function setupMocksForVoteOnAttribute(User $user, TokenInterface $token, bool $isGrantedForEntity, object $entity, bool $workflowAllowed): void | ||||
|     { | ||||
|         // Set up token to return user | ||||
|         $token->method('getUser')->willReturn($user); | ||||
|  | ||||
|         // Mock the return of an AccompanyingCourseDocument by the repository | ||||
|         $this->repository->method('findAssociatedEntityToStoredObject')->willReturn($entity); | ||||
|  | ||||
|         // Mock scenario where user is allowed to see_details of the AccompanyingCourseDocument | ||||
|         $this->security->method('isGranted')->willReturn($isGrantedForEntity); | ||||
|  | ||||
|         // Mock case where user is blocked or not by workflow | ||||
|         $this->workflowDocumentService->method('notBlockedByWorkflow')->willReturn($workflowAllowed); | ||||
|     } | ||||
|  | ||||
|     public function testSupportsOnAttribute(): void | ||||
|     { | ||||
|         [$user, $token, $subject, $entity] = $this->setupMockObjects(); | ||||
|  | ||||
|         // Setup mocks for voteOnAttribute method | ||||
|         $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true); | ||||
|         $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); | ||||
|  | ||||
|         self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, $subject)); | ||||
|     } | ||||
|  | ||||
|     public function testVoteOnAttributeAllowedAndWorkflowAllowed(): void | ||||
|     { | ||||
|         [$user, $token, $subject, $entity] = $this->setupMockObjects(); | ||||
|  | ||||
|         // Setup mocks for voteOnAttribute method | ||||
|         $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true); | ||||
|         $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); | ||||
|  | ||||
|         // The voteOnAttribute method should return True when workflow is allowed | ||||
|         self::assertTrue($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token)); | ||||
|     } | ||||
|  | ||||
|     public function testVoteOnAttributeNotAllowed(): void | ||||
|     { | ||||
|         [$user, $token, $subject, $entity] = $this->setupMockObjects(); | ||||
|  | ||||
|         // Setup mocks for voteOnAttribute method where isGranted() returns false | ||||
|         $this->setupMocksForVoteOnAttribute($user, $token, false, $entity, true); | ||||
|         $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); | ||||
|  | ||||
|         // The voteOnAttribute method should return True when workflow is allowed | ||||
|         self::assertFalse($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token)); | ||||
|     } | ||||
|  | ||||
|     public function testVoteOnAttributeAllowedWorkflowNotAllowed(): void | ||||
|     { | ||||
|         [$user, $token, $subject, $entity] = $this->setupMockObjects(); | ||||
|  | ||||
|         // Setup mocks for voteOnAttribute method | ||||
|         $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, false); | ||||
|         $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); | ||||
|  | ||||
|         // Test voteOnAttribute method | ||||
|         $attribute = StoredObjectRoleEnum::EDIT; | ||||
|         $result = $voter->voteOnAttribute($attribute, $subject, $token); | ||||
|  | ||||
|         // Assert that access is denied when workflow is not allowed | ||||
|         $this->assertFalse($result); | ||||
|     } | ||||
|  | ||||
|     public function testVoteOnAttributeAllowedWorkflowAllowedToSeeDocument(): void | ||||
|     { | ||||
|         [$user, $token, $subject, $entity] = $this->setupMockObjects(); | ||||
|  | ||||
|         // Setup mocks for voteOnAttribute method | ||||
|         $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, false); | ||||
|         $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); | ||||
|  | ||||
|         // Test voteOnAttribute method | ||||
|         $attribute = StoredObjectRoleEnum::SEE; | ||||
|         $result = $voter->voteOnAttribute($attribute, $subject, $token); | ||||
|  | ||||
|         // Assert that access is denied when workflow is not allowed | ||||
|         $this->assertTrue($result); | ||||
|     } | ||||
| } | ||||
| @@ -14,11 +14,14 @@ namespace Chill\DocStoreBundle\Tests\Security\Authorization; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter; | ||||
| use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Psr\Log\NullLogger; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
| @@ -27,97 +30,93 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; | ||||
|  */ | ||||
| class StoredObjectVoterTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataVote | ||||
|      */ | ||||
|     public function testVote(TokenInterface $token, ?object $subject, string $attribute, mixed $expected): void | ||||
|     public function testVote(array $storedObjectVotersDefinition, object $subject, string $attribute, bool $fallbackSecurityExpected, bool $securityIsGrantedResult, mixed $expected): void | ||||
|     { | ||||
|         $voter = new StoredObjectVoter(); | ||||
|         $storedObjectVoters = array_map(fn (array $definition) => $this->buildStoredObjectVoter($definition[0], $definition[1], $definition[2]), $storedObjectVotersDefinition); | ||||
|         $token = new UsernamePasswordToken(new User(), 'chill_main', ['ROLE_USER']); | ||||
|  | ||||
|         $security = $this->createMock(Security::class); | ||||
|         $security->expects($fallbackSecurityExpected ? $this->atLeastOnce() : $this->never()) | ||||
|             ->method('isGranted') | ||||
|             ->with($this->logicalOr($this->identicalTo('ROLE_USER'), $this->identicalTo('ROLE_ADMIN'))) | ||||
|             ->willReturn($securityIsGrantedResult); | ||||
|  | ||||
|         $voter = new StoredObjectVoter($security, $storedObjectVoters, new NullLogger()); | ||||
|  | ||||
|         self::assertEquals($expected, $voter->vote($token, $subject, [$attribute])); | ||||
|     } | ||||
|  | ||||
|     public function provideDataVote(): iterable | ||||
|     private function buildStoredObjectVoter(bool $supportsIsCalled, bool $supports, bool $voteOnAttribute): StoredObjectVoterInterface | ||||
|     { | ||||
|         $storedObjectVoter = $this->createMock(StoredObjectVoterInterface::class); | ||||
|         $storedObjectVoter->expects($supportsIsCalled ? $this->once() : $this->never())->method('supports') | ||||
|             ->with(self::isInstanceOf(StoredObjectRoleEnum::class), $this->isInstanceOf(StoredObject::class)) | ||||
|             ->willReturn($supports); | ||||
|         $storedObjectVoter->expects($supportsIsCalled && $supports ? $this->once() : $this->never())->method('voteOnAttribute') | ||||
|             ->with(self::isInstanceOf(StoredObjectRoleEnum::class), $this->isInstanceOf(StoredObject::class), $this->isInstanceOf(TokenInterface::class)) | ||||
|             ->willReturn($voteOnAttribute); | ||||
|  | ||||
|         return $storedObjectVoter; | ||||
|     } | ||||
|  | ||||
|     public static function provideDataVote(): iterable | ||||
|     { | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()), | ||||
|             // we try with something else than a SToredObject, the voter should abstain | ||||
|             [[false, false, false]], | ||||
|             new \stdClass(), | ||||
|             'SOMETHING', | ||||
|             false, | ||||
|             false, | ||||
|             VoterInterface::ACCESS_ABSTAIN, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), | ||||
|             $so, | ||||
|             // we try with an unsupported attribute, the voter must abstain | ||||
|             [[false, false, false]], | ||||
|             new StoredObject(), | ||||
|             'SOMETHING', | ||||
|             false, | ||||
|             false, | ||||
|             VoterInterface::ACCESS_ABSTAIN, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), | ||||
|             $so, | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), | ||||
|             $so, | ||||
|             StoredObjectRoleEnum::EDIT->value, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), | ||||
|             $so, | ||||
|             StoredObjectRoleEnum::EDIT->value, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()), | ||||
|             $so, | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(null, null), | ||||
|             // happy scenario: there is a role voter | ||||
|             [[true, true, true]], | ||||
|             new StoredObject(), | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|             false, | ||||
|             false, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|  | ||||
|         yield [ | ||||
|             $this->buildToken(null, null), | ||||
|             // there is a role voter, but not allowed to see the stored object | ||||
|             [[true, true, false]], | ||||
|             new StoredObject(), | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             false, | ||||
|             false, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface | ||||
|     { | ||||
|         $token = $this->prophesize(TokenInterface::class); | ||||
|  | ||||
|         if (null !== $storedObjectRoleEnum) { | ||||
|             $token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true); | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum); | ||||
|         } else { | ||||
|             $token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false); | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException()); | ||||
|         } | ||||
|  | ||||
|         if (null !== $storedObject) { | ||||
|             $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true); | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString()); | ||||
|         } else { | ||||
|             $token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false); | ||||
|             $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException()); | ||||
|         } | ||||
|  | ||||
|         return $token->reveal(); | ||||
|         yield [ | ||||
|             // there is no role voter, fallback to security, which does not grant access | ||||
|             [[true, false, false]], | ||||
|             new StoredObject(), | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             true, | ||||
|             false, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|         yield [ | ||||
|             // there is no role voter, fallback to security, which does grant access | ||||
|             [[true, false, false]], | ||||
|             new StoredObject(), | ||||
|             StoredObjectRoleEnum::SEE->value, | ||||
|             true, | ||||
|             true, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -38,7 +38,8 @@ class SignedUrlNormalizerTest extends KernelTestCase | ||||
|         $signedUrl = new SignedUrl( | ||||
|             'GET', | ||||
|             'https://object.store.example/container/object', | ||||
|             \DateTimeImmutable::createFromFormat('U', '1700000') | ||||
|             \DateTimeImmutable::createFromFormat('U', '1700000'), | ||||
|             'object' | ||||
|         ); | ||||
|  | ||||
|         $actual = self::$normalizer->normalize($signedUrl, 'json', [AbstractNormalizer::GROUPS => ['read']]); | ||||
| @@ -48,6 +49,7 @@ class SignedUrlNormalizerTest extends KernelTestCase | ||||
|                 'method' => 'GET', | ||||
|                 'expires' => 1_700_000, | ||||
|                 'url' => 'https://object.store.example/container/object', | ||||
|                 'object_name' => 'object', | ||||
|             ], | ||||
|             $actual | ||||
|         ); | ||||
|   | ||||
| @@ -38,6 +38,7 @@ class SignedUrlPostNormalizerTest extends KernelTestCase | ||||
|         $signedUrl = new SignedUrlPost( | ||||
|             'https://object.store.example/container/object', | ||||
|             \DateTimeImmutable::createFromFormat('U', '1700000'), | ||||
|             'abc', | ||||
|             15000, | ||||
|             1, | ||||
|             180, | ||||
| @@ -59,6 +60,7 @@ class SignedUrlPostNormalizerTest extends KernelTestCase | ||||
|                 'method' => 'POST', | ||||
|                 'expires' => 1_700_000, | ||||
|                 'url' => 'https://object.store.example/container/object', | ||||
|                 'object_name' => 'abc', | ||||
|             ], | ||||
|             $actual | ||||
|         ); | ||||
|   | ||||
| @@ -0,0 +1,99 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\PdfSignedMessage; | ||||
| use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\PdfSignedMessageHandler; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Psr\Log\NullLogger; | ||||
| use Symfony\Component\Clock\MockClock; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class PdfSignedMessageHandlerTest extends TestCase | ||||
| { | ||||
|     public function testThatObjectIsWrittenInStoredObjectManagerHappyScenario(): void | ||||
|     { | ||||
|         // a dummy stored object | ||||
|         $storedObject = new StoredObject(); | ||||
|         // build the associated EntityWorkflow, with one step with a person signature | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('new_step', $dto, 'new_transition', new \DateTimeImmutable(), new User()); | ||||
|         $step = $entityWorkflow->getCurrentStep(); | ||||
|         $signature = $step->getSignatures()->first(); | ||||
|  | ||||
|         $handler = new PdfSignedMessageHandler( | ||||
|             new NullLogger(), | ||||
|             $this->buildEntityWorkflowManager($storedObject), | ||||
|             $this->buildStoredObjectManager($storedObject, $expectedContent = '1234'), | ||||
|             $this->buildSignatureRepository($signature), | ||||
|             $this->buildEntityManager(true), | ||||
|             new MockClock('now'), | ||||
|         ); | ||||
|  | ||||
|         // we simply call the handler. The mocked StoredObjectManager will check that the "write" method is invoked once | ||||
|         // with the content "1234" | ||||
|         $handler(new PdfSignedMessage(10, $expectedContent)); | ||||
|  | ||||
|         self::assertEquals('signed', $signature->getState()->value); | ||||
|     } | ||||
|  | ||||
|     private function buildSignatureRepository(EntityWorkflowStepSignature $signature): EntityWorkflowStepSignatureRepository | ||||
|     { | ||||
|         $entityWorkflowStepSignatureRepository = $this->createMock(EntityWorkflowStepSignatureRepository::class); | ||||
|         $entityWorkflowStepSignatureRepository->method('find')->with($this->isType('int'))->willReturn($signature); | ||||
|  | ||||
|         return $entityWorkflowStepSignatureRepository; | ||||
|     } | ||||
|  | ||||
|     private function buildEntityWorkflowManager(?StoredObject $associatedStoredObject): EntityWorkflowManager | ||||
|     { | ||||
|         $entityWorkflowManager = $this->createMock(EntityWorkflowManager::class); | ||||
|         $entityWorkflowManager->method('getAssociatedStoredObject')->willReturn($associatedStoredObject); | ||||
|  | ||||
|         return $entityWorkflowManager; | ||||
|     } | ||||
|  | ||||
|     private function buildStoredObjectManager(StoredObject $expectedStoredObject, string $expectedContent): StoredObjectManagerInterface | ||||
|     { | ||||
|         $storedObjectManager = $this->createMock(StoredObjectManagerInterface::class); | ||||
|         $storedObjectManager->expects($this->once()) | ||||
|             ->method('write') | ||||
|             ->with($this->identicalTo($expectedStoredObject), $expectedContent); | ||||
|  | ||||
|         return $storedObjectManager; | ||||
|     } | ||||
|  | ||||
|     private function buildEntityManager(bool $willFlush): EntityManagerInterface | ||||
|     { | ||||
|         $em = $this->createMock(EntityManagerInterface::class); | ||||
|         $em->expects($willFlush ? $this->once() : $this->never())->method('flush'); | ||||
|         $em->expects($willFlush ? $this->once() : $this->never())->method('clear'); | ||||
|  | ||||
|         return $em; | ||||
|     } | ||||
| } | ||||
| @@ -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\DocStoreBundle\Tests\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\PdfSignedMessage; | ||||
| use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\PdfSignedMessageSerializer; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class PdfSignedMessageSerializerTest extends TestCase | ||||
| { | ||||
|     public function testDecode(): void | ||||
|     { | ||||
|         $asString = <<<'JSON' | ||||
|             {"signatureId": 0, "content": "dGVzdAo="} | ||||
|             JSON; | ||||
|  | ||||
|         $actual = $this->buildSerializer()->decode(['body' => $asString]); | ||||
|  | ||||
|         self::assertInstanceOf(Envelope::class, $actual); | ||||
|         $message = $actual->getMessage(); | ||||
|         self::assertInstanceOf(PdfSignedMessage::class, $message); | ||||
|         self::assertEquals("test\n", $message->content); | ||||
|         self::assertEquals(0, $message->signatureId); | ||||
|     } | ||||
|  | ||||
|     public function testEncode(): void | ||||
|     { | ||||
|         $envelope = new Envelope( | ||||
|             new PdfSignedMessage(0, "test\n") | ||||
|         ); | ||||
|  | ||||
|         $actual = $this->buildSerializer()->encode($envelope); | ||||
|  | ||||
|         self::assertIsArray($actual); | ||||
|         self::assertArrayHasKey('body', $actual); | ||||
|         self::assertArrayHasKey('headers', $actual); | ||||
|         self::assertEquals([], $actual['headers']); | ||||
|  | ||||
|         self::assertEquals(<<<'JSON' | ||||
|                 {"signatureId":0,"content":"dGVzdAo="} | ||||
|                 JSON, $actual['body']); | ||||
|     } | ||||
|  | ||||
|     private function buildSerializer(): PdfSignedMessageSerializer | ||||
|     { | ||||
|         return new PdfSignedMessageSerializer(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,137 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Service\Signature\Driver\BaseSigner; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFPage; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; | ||||
| use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessage; | ||||
| use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessageSerializer; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Serializer\Exception\UnexpectedValueException; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| use Symfony\Component\Serializer\Serializer; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class RequestPdfSignMessageSerializerTest extends TestCase | ||||
| { | ||||
|     public function testEncode(): void | ||||
|     { | ||||
|         $serializer = $this->buildSerializer(); | ||||
|  | ||||
|         $envelope = new Envelope( | ||||
|             $request = new RequestPdfSignMessage( | ||||
|                 0, | ||||
|                 new PDFSignatureZone(0, 10.0, 10.0, 180.0, 180.0, new PDFPage(0, 500.0, 800.0)), | ||||
|                 0, | ||||
|                 'metadata to add to the signature', | ||||
|                 'Mme Caroline Diallo', | ||||
|                 'abc' | ||||
|             ), | ||||
|         ); | ||||
|  | ||||
|         $actual = $serializer->encode($envelope); | ||||
|         $expectedBody = json_encode([ | ||||
|             'signatureId' => $request->signatureId, | ||||
|             'signatureZoneIndex' => $request->signatureZoneIndex, | ||||
|             'signatureZone' => ['x' => 10.0], | ||||
|             'reason' => $request->reason, | ||||
|             'signerText' => $request->signerText, | ||||
|             'content' => base64_encode($request->content), | ||||
|         ]); | ||||
|  | ||||
|         self::assertIsArray($actual); | ||||
|         self::assertArrayHasKey('body', $actual); | ||||
|         self::assertArrayHasKey('headers', $actual); | ||||
|         self::assertEquals($expectedBody, $actual['body']); | ||||
|     } | ||||
|  | ||||
|     public function testDecode(): void | ||||
|     { | ||||
|         $serializer = $this->buildSerializer(); | ||||
|  | ||||
|         $request = new RequestPdfSignMessage( | ||||
|             0, | ||||
|             new PDFSignatureZone(0, 10.0, 10.0, 180.0, 180.0, new PDFPage(0, 500.0, 800.0)), | ||||
|             0, | ||||
|             'metadata to add to the signature', | ||||
|             'Mme Caroline Diallo', | ||||
|             'abc' | ||||
|         ); | ||||
|  | ||||
|         $bodyAsString = json_encode([ | ||||
|             'signatureId' => $request->signatureId, | ||||
|             'signatureZoneIndex' => $request->signatureZoneIndex, | ||||
|             'signatureZone' => ['x' => 10.0], | ||||
|             'reason' => $request->reason, | ||||
|             'signerText' => $request->signerText, | ||||
|             'content' => base64_encode($request->content), | ||||
|         ], JSON_THROW_ON_ERROR); | ||||
|  | ||||
|         $actual = $serializer->decode([ | ||||
|             'body' => $bodyAsString, | ||||
|             'headers' => [ | ||||
|                 'Message' => RequestPdfSignMessage::class, | ||||
|             ], | ||||
|         ]); | ||||
|  | ||||
|         self::assertInstanceOf(RequestPdfSignMessage::class, $actual->getMessage()); | ||||
|         self::assertEquals($request->signatureId, $actual->getMessage()->signatureId); | ||||
|         self::assertEquals($request->signatureZoneIndex, $actual->getMessage()->signatureZoneIndex); | ||||
|         self::assertEquals($request->reason, $actual->getMessage()->reason); | ||||
|         self::assertEquals($request->signerText, $actual->getMessage()->signerText); | ||||
|         self::assertEquals($request->content, $actual->getMessage()->content); | ||||
|         self::assertNotNull($actual->getMessage()->PDFSignatureZone); | ||||
|     } | ||||
|  | ||||
|     private function buildSerializer(): RequestPdfSignMessageSerializer | ||||
|     { | ||||
|         $normalizer = | ||||
|             new class () implements NormalizerInterface { | ||||
|                 public function normalize($object, ?string $format = null, array $context = []): array | ||||
|                 { | ||||
|                     if (!$object instanceof PDFSignatureZone) { | ||||
|                         throw new UnexpectedValueException('expected RequestPdfSignMessage'); | ||||
|                     } | ||||
|  | ||||
|                     return [ | ||||
|                         'x' => $object->x, | ||||
|                     ]; | ||||
|                 } | ||||
|  | ||||
|                 public function supportsNormalization($data, ?string $format = null): bool | ||||
|                 { | ||||
|                     return $data instanceof PDFSignatureZone; | ||||
|                 } | ||||
|             }; | ||||
|         $denormalizer = new class () implements DenormalizerInterface { | ||||
|             public function denormalize($data, string $type, ?string $format = null, array $context = []) | ||||
|             { | ||||
|                 return new PDFSignatureZone(0, 10.0, 10.0, 180.0, 180.0, new PDFPage(0, 500.0, 800.0)); | ||||
|             } | ||||
|  | ||||
|             public function supportsDenormalization($data, string $type, ?string $format = null) | ||||
|             { | ||||
|                 return PDFSignatureZone::class === $type; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         $serializer = new Serializer([$normalizer, $denormalizer]); | ||||
|  | ||||
|         return new RequestPdfSignMessageSerializer($serializer, $serializer); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,79 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Tests\Service\Signature; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFPage; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class PDFSignatureZoneParserTest extends TestCase | ||||
| { | ||||
|     private static PDFSignatureZoneParser $parser; | ||||
|  | ||||
|     public static function setUpBeforeClass(): void | ||||
|     { | ||||
|         self::$parser = new PDFSignatureZoneParser(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideFiles | ||||
|      * | ||||
|      * @param list<PDFSignatureZone> $expected | ||||
|      */ | ||||
|     public function testFindSignatureZones(string $filePath, array $expected): void | ||||
|     { | ||||
|         $content = file_get_contents($filePath); | ||||
|  | ||||
|         if (false === $content) { | ||||
|             throw new \LogicException("Unable to read file {$filePath}"); | ||||
|         } | ||||
|  | ||||
|         $actual = self::$parser->findSignatureZones($content); | ||||
|  | ||||
|         self::assertEquals(count($expected), count($actual)); | ||||
|  | ||||
|         foreach ($actual as $index => $signatureZone) { | ||||
|             self::assertObjectEquals($expected[$index], $signatureZone); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static function provideFiles(): iterable | ||||
|     { | ||||
|         yield [ | ||||
|             __DIR__.'/data/signature_2_signature_page_1.pdf', | ||||
|             [ | ||||
|                 new PDFSignatureZone( | ||||
|                     0, | ||||
|                     127.7, | ||||
|                     95.289, | ||||
|                     90.0, | ||||
|                     180.0, | ||||
|                     $page = new PDFPage(0, 595.30393, 841.8897) | ||||
|                 ), | ||||
|                 new PDFSignatureZone( | ||||
|                     1, | ||||
|                     269.5, | ||||
|                     95.289, | ||||
|                     90.0, | ||||
|                     180.0, | ||||
|                     $page, | ||||
|                 ), | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| @@ -202,7 +202,8 @@ final class StoredObjectManagerTest extends TestCase | ||||
|         $response = new SignedUrl( | ||||
|             'PUT', | ||||
|             'https://example.com/'.$storedObject->getFilename(), | ||||
|             new \DateTimeImmutable('1 hours') | ||||
|             new \DateTimeImmutable('1 hours'), | ||||
|             $storedObject->getFilename() | ||||
|         ); | ||||
|  | ||||
|         $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class); | ||||
|   | ||||
| @@ -43,7 +43,7 @@ class AsyncFileExistsValidatorTest extends ConstraintValidatorTestCase | ||||
|  | ||||
|         $generator = $this->prophesize(TempUrlGeneratorInterface::class); | ||||
|         $generator->generate(Argument::in(['GET', 'HEAD']), Argument::type('string'), Argument::any()) | ||||
|             ->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'))); | ||||
|             ->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'), $args[1])); | ||||
|  | ||||
|         return new AsyncFileExistsValidator($generator->reveal(), $client); | ||||
|     } | ||||
|   | ||||
| @@ -12,27 +12,25 @@ declare(strict_types=1); | ||||
| namespace Chill\DocStoreBundle\Workflow; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; | ||||
| use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface; | ||||
| use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandlerInterface | ||||
| /** | ||||
|  * @implements EntityWorkflowWithStoredObjectHandlerInterface<AccompanyingCourseDocument> | ||||
|  */ | ||||
| readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface | ||||
| { | ||||
|     private readonly EntityRepository $repository; | ||||
|  | ||||
|     /** | ||||
|      * TODO: injecter le repository directement. | ||||
|      */ | ||||
|     public function __construct( | ||||
|         EntityManagerInterface $em, | ||||
|         private readonly TranslatorInterface $translator | ||||
|     ) { | ||||
|         $this->repository = $em->getRepository(AccompanyingCourseDocument::class); | ||||
|     } | ||||
|         private TranslatorInterface $translator, | ||||
|         private EntityWorkflowRepository $workflowRepository, | ||||
|         private AccompanyingCourseDocumentRepository $repository | ||||
|     ) {} | ||||
|  | ||||
|     public function getDeletionRoles(): array | ||||
|     { | ||||
| @@ -73,8 +71,6 @@ class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandler | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param AccompanyingCourseDocument $object | ||||
|      * | ||||
|      * @return array[] | ||||
|      */ | ||||
|     public function getRelatedObjects(object $object): array | ||||
| @@ -122,8 +118,22 @@ class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandler | ||||
|         return AccompanyingCourseDocument::class === $entityWorkflow->getRelatedEntityClass(); | ||||
|     } | ||||
|  | ||||
|     public function getAssociatedStoredObject(EntityWorkflow $entityWorkflow): ?StoredObject | ||||
|     { | ||||
|         return $this->getRelatedEntity($entityWorkflow)?->getObject(); | ||||
|     } | ||||
|  | ||||
|     public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool | ||||
|     { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public function findByRelatedEntity(object $object): array | ||||
|     { | ||||
|         if (!$object instanceof AccompanyingCourseDocument) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         return $this->workflowRepository->findByRelatedEntity(AccompanyingCourseDocument::class, $object->getId()); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -5,4 +5,5 @@ module.exports = function(encore) | ||||
|     }); | ||||
|     encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts'); | ||||
|     encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index'); | ||||
|     encore.addEntry('vue_document_signature', __dirname + '/Resources/public/vuejs/DocumentSignature/index.ts'); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										13
									
								
								src/Bundle/ChillDocStoreBundle/config/services/security.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/Bundle/ChillDocStoreBundle/config/services/security.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| services: | ||||
|     _defaults: | ||||
|         autowire: true | ||||
|         autoconfigure: true | ||||
|     Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter: | ||||
|         arguments: | ||||
|             $storedObjectVoters: !tagged_iterator stored_object_voter | ||||
|         tags: | ||||
|             - { name: security.voter } | ||||
|  | ||||
|     Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter: | ||||
|         tags: | ||||
|             - { name: security.voter } | ||||
| @@ -31,7 +31,7 @@ use Symfony\Component\Validator\Constraints as Assert; | ||||
| /** | ||||
|  * Class Event. | ||||
|  */ | ||||
| #[ORM\Entity(repositoryClass: \Chill\EventBundle\Repository\EventRepository::class)] | ||||
| #[ORM\Entity] | ||||
| #[ORM\HasLifecycleCallbacks] | ||||
| #[ORM\Table(name: 'chill_event_event')] | ||||
| class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInterface, TrackUpdateInterface | ||||
| @@ -62,9 +62,9 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter | ||||
|     private ?string $name = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<Participation> | ||||
|      * @var Collection<int, Participation> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: Participation::class, mappedBy: 'event')] | ||||
|     #[ORM\OneToMany(mappedBy: 'event', targetEntity: Participation::class)] | ||||
|     private Collection $participations; | ||||
|  | ||||
|     #[Assert\NotNull] | ||||
| @@ -79,7 +79,7 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter | ||||
|     private ?Location $location = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<StoredObject> | ||||
|      * @var Collection<int, StoredObject> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: StoredObject::class, cascade: ['persist', 'refresh'])] | ||||
|     #[ORM\JoinTable('chill_event_event_documents')] | ||||
| @@ -192,7 +192,7 @@ class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInter | ||||
|     { | ||||
|         $iterator = iterator_to_array($this->participations->getIterator()); | ||||
|  | ||||
|         uasort($iterator, static fn ($first, $second) => strnatcasecmp((string) $first->getPerson()->getFirstName(), (string) $second->getPerson()->getFirstName())); | ||||
|         uasort($iterator, static fn ($first, $second) => strnatcasecmp($first->getPerson()->getFirstName(), $second->getPerson()->getFirstName())); | ||||
|  | ||||
|         return new \ArrayIterator($iterator); | ||||
|     } | ||||
|   | ||||
| @@ -38,13 +38,13 @@ class EventType | ||||
|     private $name; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<Role> | ||||
|      * @var Collection<int, Role> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: Role::class, mappedBy: 'type')] | ||||
|     #[ORM\OneToMany(mappedBy: 'type', targetEntity: Role::class)] | ||||
|     private Collection $roles; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<Status> | ||||
|      * @var Collection<int, Status> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: Status::class, mappedBy: 'type')] | ||||
|     private Collection $statuses; | ||||
|   | ||||
| @@ -12,7 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\EventBundle\Form\ChoiceLoader; | ||||
|  | ||||
| use Chill\EventBundle\Entity\Event; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Chill\EventBundle\Repository\EventRepository; | ||||
| use Symfony\Component\Form\ChoiceList\ChoiceListInterface; | ||||
| use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; | ||||
|  | ||||
| @@ -26,9 +26,6 @@ class EventChoiceLoader implements ChoiceLoaderInterface | ||||
|      */ | ||||
|     protected $centers = []; | ||||
|  | ||||
|     /** | ||||
|      * @var EntityRepository | ||||
|      */ | ||||
|     protected $eventRepository; | ||||
|  | ||||
|     /** | ||||
| @@ -40,7 +37,7 @@ class EventChoiceLoader implements ChoiceLoaderInterface | ||||
|      * EventChoiceLoader constructor. | ||||
|      */ | ||||
|     public function __construct( | ||||
|         EntityRepository $eventRepository, | ||||
|         EventRepository $eventRepository, | ||||
|         ?array $centers = null | ||||
|     ) { | ||||
|         $this->eventRepository = $eventRepository; | ||||
|   | ||||
| @@ -11,17 +11,65 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\EventBundle\Repository; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\EventBundle\Entity\Event; | ||||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | ||||
| use Doctrine\Persistence\ManagerRegistry; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| /** | ||||
|  * Class EventRepository. | ||||
|  */ | ||||
| class EventRepository extends ServiceEntityRepository | ||||
| class EventRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface | ||||
| { | ||||
|     public function __construct(ManagerRegistry $registry) | ||||
|     private readonly EntityRepository $repository; | ||||
|  | ||||
|     public function __construct(EntityManagerInterface $entityManager) | ||||
|     { | ||||
|         parent::__construct($registry, Event::class); | ||||
|         $this->repository = $entityManager->getRepository(Event::class); | ||||
|     } | ||||
|  | ||||
|     public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder | ||||
|     { | ||||
|         return $this->repository->createQueryBuilder($alias, $indexBy); | ||||
|     } | ||||
|  | ||||
|     public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object | ||||
|     { | ||||
|         $qb = $this->createQueryBuilder('e'); | ||||
|         $query = $qb | ||||
|             ->join('e.documents', 'ed') | ||||
|             ->where('ed.id = :storedObjectId') | ||||
|             ->setParameter('storedObjectId', $storedObject->getId()) | ||||
|             ->getQuery(); | ||||
|  | ||||
|         return $query->getOneOrNullResult(); | ||||
|     } | ||||
|  | ||||
|     public function find($id) | ||||
|     { | ||||
|         return $this->repository->find($id); | ||||
|     } | ||||
|  | ||||
|     public function findAll(): array | ||||
|     { | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array | ||||
|     { | ||||
|         return $this->repository->findBy($criteria, $orderBy, $limit, $offset); | ||||
|     } | ||||
|  | ||||
|     public function findOneBy(array $criteria) | ||||
|     { | ||||
|         return $this->repository->findOneBy($criteria); | ||||
|     } | ||||
|  | ||||
|     public function getClassName(): string | ||||
|     { | ||||
|         return Event::class; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,55 @@ | ||||
| <?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\EventBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter; | ||||
| use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; | ||||
| use Chill\EventBundle\Entity\Event; | ||||
| use Chill\EventBundle\Repository\EventRepository; | ||||
| use Chill\EventBundle\Security\EventVoter; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class EventStoredObjectVoter extends AbstractStoredObjectVoter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly EventRepository $repository, | ||||
|         Security $security, | ||||
|         WorkflowStoredObjectPermissionHelper $workflowDocumentService | ||||
|     ) { | ||||
|         parent::__construct($security, $workflowDocumentService); | ||||
|     } | ||||
|  | ||||
|     protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|     { | ||||
|         return $this->repository; | ||||
|     } | ||||
|  | ||||
|     protected function getClass(): string | ||||
|     { | ||||
|         return Event::class; | ||||
|     } | ||||
|  | ||||
|     protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|     { | ||||
|         return match ($attribute) { | ||||
|             StoredObjectRoleEnum::EDIT => EventVoter::UPDATE, | ||||
|             StoredObjectRoleEnum::SEE => EventVoter::SEE_DETAILS, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     protected function canBeAssociatedWithWorkflow(): bool | ||||
|     { | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
| @@ -96,7 +96,7 @@ class SearchController extends AbstractController | ||||
|         return $this->render('@ChillMain/Search/choose_list.html.twig'); | ||||
|     } | ||||
|  | ||||
|     #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/search.{_format}', name: 'chill_main_search', requirements: ['_format' => 'html|json'], defaults: ['_format' => 'html'])] | ||||
|     #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/search.{_format}', name: 'chill_main_search', requirements: ['_format' => 'html|json', '_locale' => '[a-z]{1,3}'], defaults: ['_format' => 'html'])] | ||||
|     public function searchAction(Request $request, mixed $_format) | ||||
|     { | ||||
|         $pattern = trim((string) $request->query->get('q', '')); | ||||
|   | ||||
| @@ -11,25 +11,30 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; | ||||
| use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowComment; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; | ||||
| use Chill\MainBundle\Form\EntityWorkflowCommentType; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; | ||||
| use Chill\MainBundle\Form\WorkflowSignatureMetadataType; | ||||
| use Chill\MainBundle\Form\WorkflowStepType; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; | ||||
| use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter; | ||||
| use Chill\MainBundle\Security\ChillSecurity; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Form\Extension\Core\Type\FormType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\SubmitType; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||||
| use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Validator\Validator\ValidatorInterface; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
| @@ -38,7 +43,20 @@ use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| class WorkflowController extends AbstractController | ||||
| { | ||||
|     public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly ValidatorInterface $validator, private readonly PaginatorFactory $paginatorFactory, private readonly Registry $registry, private readonly EntityManagerInterface $entityManager, private readonly TranslatorInterface $translator, private readonly ChillSecurity $security, private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry) {} | ||||
|     public function __construct( | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|         private readonly EntityWorkflowRepository $entityWorkflowRepository, | ||||
|         private readonly ValidatorInterface $validator, | ||||
|         private readonly StoredObjectManagerInterface $storedObjectManagerInterface, | ||||
|         private readonly PaginatorFactory $paginatorFactory, | ||||
|         private readonly Registry $registry, | ||||
|         private readonly EntityManagerInterface $entityManager, | ||||
|         private readonly TranslatorInterface $translator, | ||||
|         private readonly ChillSecurity $security, | ||||
|         private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, | ||||
|         private readonly ClockInterface $clock, | ||||
|         private readonly PDFSignatureZoneParser $PDFSignatureZoneParser, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route(path: '/{_locale}/main/workflow/create', name: 'chill_main_workflow_create')] | ||||
|     public function create(Request $request): Response | ||||
| @@ -276,10 +294,11 @@ class WorkflowController extends AbstractController | ||||
|         $handler = $this->entityWorkflowManager->getHandler($entityWorkflow); | ||||
|         $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); | ||||
|         $errors = []; | ||||
|         $signatures = $entityWorkflow->getCurrentStep()->getSignatures(); | ||||
|  | ||||
|         if (\count($workflow->getEnabledTransitions($entityWorkflow)) > 0) { | ||||
|             // possible transition | ||||
|  | ||||
|             $stepDTO = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|             $usersInvolved = $entityWorkflow->getUsersInvolved(); | ||||
|             $currentUserFound = array_search($this->security->getUser(), $usersInvolved, true); | ||||
|  | ||||
| @@ -289,9 +308,8 @@ class WorkflowController extends AbstractController | ||||
|  | ||||
|             $transitionForm = $this->createForm( | ||||
|                 WorkflowStepType::class, | ||||
|                 $entityWorkflow->getCurrentStep(), | ||||
|                 $stepDTO, | ||||
|                 [ | ||||
|                     'transition' => true, | ||||
|                     'entity_workflow' => $entityWorkflow, | ||||
|                     'suggested_users' => $usersInvolved, | ||||
|                 ] | ||||
| @@ -310,12 +328,14 @@ class WorkflowController extends AbstractController | ||||
|                     throw $this->createAccessDeniedException(sprintf("not allowed to apply transition {$transition}: %s", implode(', ', $msgs))); | ||||
|                 } | ||||
|  | ||||
|                 // TODO symfony 5: add those "future" on context ($workflow->apply($entityWorkflow, $transition, $context) | ||||
|                 $entityWorkflow->futureCcUsers = $transitionForm['future_cc_users']->getData() ?? []; | ||||
|                 $entityWorkflow->futureDestUsers = $transitionForm['future_dest_users']->getData() ?? []; | ||||
|                 $entityWorkflow->futureDestEmails = $transitionForm['future_dest_emails']->getData() ?? []; | ||||
|                 $byUser = $this->security->getUser(); | ||||
|  | ||||
|                 $workflow->apply($entityWorkflow, $transition); | ||||
|                 $workflow->apply($entityWorkflow, $transition, [ | ||||
|                     'context' => $stepDTO, | ||||
|                     'byUser' => $byUser, | ||||
|                     'transition' => $transition, | ||||
|                     'transitionAt' => $this->clock->now(), | ||||
|                 ]); | ||||
|  | ||||
|                 $this->entityManager->flush(); | ||||
|  | ||||
| @@ -327,22 +347,6 @@ class WorkflowController extends AbstractController | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /* | ||||
|         $commentForm = $this->createForm(EntityWorkflowCommentType::class, $newComment = new EntityWorkflowComment()); | ||||
|         $commentForm->handleRequest($request); | ||||
|  | ||||
|         if ($commentForm->isSubmitted() && $commentForm->isValid()) { | ||||
|             $this->entityManager->persist($newComment); | ||||
|             $this->entityManager->flush(); | ||||
|  | ||||
|             $this->addFlash('success', $this->translator->trans('workflow.Comment added')); | ||||
|  | ||||
|             return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]); | ||||
|         } elseif ($commentForm->isSubmitted() && !$commentForm->isValid()) { | ||||
|             $this->addFlash('error', $this->translator->trans('This form contains errors')); | ||||
|         } | ||||
|          */ | ||||
|  | ||||
|         return $this->render( | ||||
|             '@ChillMain/Workflow/index.html.twig', | ||||
|             [ | ||||
| @@ -352,7 +356,7 @@ class WorkflowController extends AbstractController | ||||
|                 'transition_form' => isset($transitionForm) ? $transitionForm->createView() : null, | ||||
|                 'entity_workflow' => $entityWorkflow, | ||||
|                 'transition_form_errors' => $errors, | ||||
|                 // 'comment_form' => $commentForm->createView(), | ||||
|                 'signatures' => $signatures, | ||||
|             ] | ||||
|         ); | ||||
|     } | ||||
| @@ -371,4 +375,78 @@ class WorkflowController extends AbstractController | ||||
|  | ||||
|         return $lines; | ||||
|     } | ||||
|  | ||||
|     #[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/metadata', name: 'chill_main_workflow_signature_metadata')] | ||||
|     public function addSignatureMetadata(int $signature_id, Request $request): Response | ||||
|     { | ||||
|         $signature = $this->entityManager->getRepository(EntityWorkflowStepSignature::class)->find($signature_id); | ||||
|  | ||||
|         if ($signature->getSigner() instanceof User) { | ||||
|             return $this->redirectToRoute('chill_main_workflow_signature', ['signature_id' => $signature_id]); | ||||
|         } | ||||
|  | ||||
|         $metadataForm = $this->createForm(WorkflowSignatureMetadataType::class); | ||||
|         $metadataForm->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]); | ||||
|  | ||||
|         $metadataForm->handleRequest($request); | ||||
|  | ||||
|         if ($metadataForm->isSubmitted() && $metadataForm->isValid()) { | ||||
|             $data = $metadataForm->getData(); | ||||
|  | ||||
|             $signature->setSignatureMetadata( | ||||
|                 [ | ||||
|                     'base_signer' => [ | ||||
|                         'document_type' => $data['documentType'], | ||||
|                         'document_number' => $data['documentNumber'], | ||||
|                         'expiration_date' => $data['expirationDate'], | ||||
|                     ], | ||||
|                 ] | ||||
|             ); | ||||
|  | ||||
|             $this->entityManager->persist($signature); | ||||
|             $this->entityManager->flush(); | ||||
|  | ||||
|             return $this->redirectToRoute('chill_main_workflow_signature', ['signature_id' => $signature_id]); | ||||
|         } | ||||
|  | ||||
|         return $this->render( | ||||
|             '@ChillMain/Workflow/_signature_metadata.html.twig', | ||||
|             [ | ||||
|                 'metadata_form' => $metadataForm->createView(), | ||||
|                 'person' => $signature->getSigner(), | ||||
|             ] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/sign', name: 'chill_main_workflow_signature', methods: 'GET')] | ||||
|     public function addSignature(int $signature_id, Request $request): Response | ||||
|     { | ||||
|         $signature = $this->entityManager->getRepository(EntityWorkflowStepSignature::class)->find($signature_id); | ||||
|         $entityWorkflow = $signature->getStep()->getEntityWorkflow(); | ||||
|  | ||||
|         $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); | ||||
|         if (null === $storedObject) { | ||||
|             throw new NotFoundHttpException('No stored object found'); | ||||
|         } | ||||
|  | ||||
|         $zones = []; | ||||
|         $content = $this->storedObjectManagerInterface->read($storedObject); | ||||
|         if (null != $content) { | ||||
|             $zones = $this->PDFSignatureZoneParser->findSignatureZones($content); | ||||
|         } | ||||
|  | ||||
|         $signatureClient = []; | ||||
|         $signatureClient['id'] = $signature->getId(); | ||||
|         $signatureClient['storedObject'] = [ | ||||
|             'filename' => $storedObject->getFilename(), | ||||
|             'iv' => $storedObject->getIv(), | ||||
|             'keyInfos' => $storedObject->getKeyInfos(), | ||||
|         ]; | ||||
|         $signatureClient['zones'] = $zones; | ||||
|  | ||||
|         return $this->render( | ||||
|             '@ChillMain/Workflow/_signature_sign.html.twig', | ||||
|             ['signature' => $signatureClient] | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -85,6 +85,29 @@ class Configuration implements ConfigurationInterface | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() // end of notifications | ||||
|             ->arrayNode('workflow_signature') | ||||
|             ->children() | ||||
|             ->arrayNode('base_signer') | ||||
|             ->children() | ||||
|             ->arrayNode('document_kinds') | ||||
|             ->arrayPrototype() | ||||
|             ->children() | ||||
|             ->scalarNode('key')->cannotBeEmpty()->end() | ||||
|             ->arrayNode('labels') | ||||
|             ->arrayPrototype() | ||||
|             ->children() | ||||
|             ->scalarNode('lang')->cannotBeEmpty()->end() | ||||
|             ->scalarNode('label')->cannotBeEmpty()->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() // end of workflow signature document types | ||||
|             ->arrayNode('phone_helper') | ||||
|             ->canBeUnset() | ||||
|             ->children() | ||||
|   | ||||
| @@ -92,7 +92,7 @@ class Address implements TrackCreationInterface, TrackUpdateInterface | ||||
|      * This list is computed by a materialized view. It won't be populated until a refresh is done | ||||
|      * on the materialized view. | ||||
|      * | ||||
|      * @var Collection<GeographicalUnit> | ||||
|      * @var Collection<int, GeographicalUnit> | ||||
|      * | ||||
|      * @readonly | ||||
|      */ | ||||
|   | ||||
| @@ -21,9 +21,9 @@ use Symfony\Component\Serializer\Annotation as Serializer; | ||||
| class Center implements HasCenterInterface, \Stringable | ||||
| { | ||||
|     /** | ||||
|      * @var Collection<GroupCenter> | ||||
|      * @var Collection<int, GroupCenter> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: GroupCenter::class, mappedBy: 'center')] | ||||
|     #[ORM\OneToMany(mappedBy: 'center', targetEntity: GroupCenter::class)] | ||||
|     private Collection $groupCenters; | ||||
|  | ||||
|     #[Serializer\Groups(['docgen:read'])] | ||||
| @@ -40,7 +40,7 @@ class Center implements HasCenterInterface, \Stringable | ||||
|     private bool $isActive = true; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<Regroupment> | ||||
|      * @var Collection<int, Regroupment> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: Regroupment::class, mappedBy: 'centers')] | ||||
|     private Collection $regroupments; | ||||
|   | ||||
| @@ -36,9 +36,9 @@ class GeographicalUnitLayer | ||||
|     private string $refId = ''; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<GeographicalUnit> | ||||
|      * @var Collection<int, GeographicalUnit> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: GeographicalUnit::class, mappedBy: 'layer')] | ||||
|     #[ORM\OneToMany(mappedBy: 'layer', targetEntity: GeographicalUnit::class)] | ||||
|     private Collection $units; | ||||
|  | ||||
|     public function __construct() | ||||
|   | ||||
| @@ -34,7 +34,7 @@ class GroupCenter | ||||
|     private ?PermissionsGroup $permissionsGroup = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User::class> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'groupCenters')] | ||||
|     private Collection $users; | ||||
|   | ||||
| @@ -30,7 +30,7 @@ class Notification implements TrackUpdateInterface | ||||
|     private array $addedAddresses = []; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: User::class)] | ||||
|     #[ORM\JoinTable(name: 'chill_main_notification_addresses_user')] | ||||
| @@ -54,9 +54,9 @@ class Notification implements TrackUpdateInterface | ||||
|     private ?ArrayCollection $addressesOnLoad = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<NotificationComment> | ||||
|      * @var Collection<int, NotificationComment> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: NotificationComment::class, mappedBy: 'notification', orphanRemoval: true)] | ||||
|     #[ORM\OneToMany(mappedBy: 'notification', targetEntity: NotificationComment::class, orphanRemoval: true)] | ||||
|     #[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])] | ||||
|     private Collection $comments; | ||||
|  | ||||
| @@ -88,7 +88,7 @@ class Notification implements TrackUpdateInterface | ||||
|     private string $title = ''; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: User::class)] | ||||
|     #[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')] | ||||
|   | ||||
| @@ -28,9 +28,9 @@ class PermissionsGroup | ||||
|     private array $flags = []; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<GroupCenter> | ||||
|      * @var Collection<int, GroupCenter> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: GroupCenter::class, mappedBy: 'permissionsGroup')] | ||||
|     #[ORM\OneToMany(mappedBy: 'permissionsGroup', targetEntity: GroupCenter::class)] | ||||
|     private Collection $groupCenters; | ||||
|  | ||||
|     #[ORM\Id] | ||||
| @@ -42,7 +42,7 @@ class PermissionsGroup | ||||
|     private string $name = ''; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<RoleScope> | ||||
|      * @var Collection<int, RoleScope> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: RoleScope::class, inversedBy: 'permissionsGroups', cascade: ['persist'])] | ||||
|     #[ORM\Cache(usage: 'NONSTRICT_READ_WRITE')] | ||||
|   | ||||
| @@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM; | ||||
| class Regroupment | ||||
| { | ||||
|     /** | ||||
|      * @var Collection<Center> | ||||
|      * @var Collection<int, Center> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: Center::class, inversedBy: 'regroupments')] | ||||
|     #[ORM\Id] | ||||
|   | ||||
| @@ -26,7 +26,7 @@ class RoleScope | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<PermissionsGroup> | ||||
|      * @var Collection<int, PermissionsGroup> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: PermissionsGroup::class, mappedBy: 'roleScopes')] | ||||
|     private Collection $permissionsGroups; | ||||
|   | ||||
| @@ -42,9 +42,9 @@ class Scope | ||||
|     private array $name = []; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<RoleScope> | ||||
|      * @var Collection<int, RoleScope> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: RoleScope::class, mappedBy: 'scope')] | ||||
|     #[ORM\OneToMany(mappedBy: 'scope', targetEntity: RoleScope::class)] | ||||
|     #[ORM\Cache(usage: 'NONSTRICT_READ_WRITE')] | ||||
|     private Collection $roleScopes; | ||||
|  | ||||
|   | ||||
| @@ -64,7 +64,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|     private bool $enabled = true; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<GroupCenter> | ||||
|      * @var Collection<int, \Chill\MainBundle\Entity\GroupCenter> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: GroupCenter::class, inversedBy: 'users')] | ||||
|     #[ORM\Cache(usage: 'NONSTRICT_READ_WRITE')] | ||||
| @@ -83,9 +83,9 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|     private ?Location $mainLocation = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection&Selectable<int, UserScopeHistory> | ||||
|      * @var \Doctrine\Common\Collections\Collection<int, \Chill\MainBundle\Entity\User\UserScopeHistory>&Selectable | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: UserScopeHistory::class, mappedBy: 'user', cascade: ['persist', 'remove'], orphanRemoval: true)] | ||||
|     #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserScopeHistory::class, cascade: ['persist', 'remove'], orphanRemoval: true)] | ||||
|     private Collection&Selectable $scopeHistories; | ||||
|  | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] | ||||
| @@ -98,9 +98,9 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|     private ?string $salt = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection&Selectable<int, UserJobHistory> | ||||
|      * @var \Doctrine\Common\Collections\Collection<int, \Chill\MainBundle\Entity\User\UserJobHistory>&Selectable | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: UserJobHistory::class, mappedBy: 'user', cascade: ['persist', 'remove'], orphanRemoval: true)] | ||||
|     #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserJobHistory::class, cascade: ['persist', 'remove'], orphanRemoval: true)] | ||||
|     private Collection&Selectable $jobHistories; | ||||
|  | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 80)] | ||||
|   | ||||
| @@ -17,9 +17,9 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation; | ||||
| use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; | ||||
| use Doctrine\Common\Collections\ArrayCollection; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Doctrine\Common\Collections\Order; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
| @@ -35,36 +35,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface | ||||
|     use TrackUpdateTrait; | ||||
|  | ||||
|     /** | ||||
|      * a list of future cc users for the next steps. | ||||
|      * | ||||
|      * @var array|User[] | ||||
|      */ | ||||
|     public array $futureCcUsers = []; | ||||
|  | ||||
|     /** | ||||
|      * a list of future dest emails for the next steps. | ||||
|      * | ||||
|      * This is in used in order to let controller inform who will be the future emails which will validate | ||||
|      * the next step. This is necessary to perform some computation about the next emails, before they are | ||||
|      * associated to the entity EntityWorkflowStep. | ||||
|      * | ||||
|      * @var array|string[] | ||||
|      */ | ||||
|     public array $futureDestEmails = []; | ||||
|  | ||||
|     /** | ||||
|      * a list of future dest users for the next steps. | ||||
|      * | ||||
|      * This is in used in order to let controller inform who will be the future users which will validate | ||||
|      * the next step. This is necessary to perform some computation about the next users, before they are | ||||
|      * associated to the entity EntityWorkflowStep. | ||||
|      * | ||||
|      * @var array|User[] | ||||
|      */ | ||||
|     public array $futureDestUsers = []; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<EntityWorkflowComment> | ||||
|      * @var Collection<int, \Chill\MainBundle\Entity\Workflow\EntityWorkflowComment> | ||||
|      */ | ||||
|     #[ORM\OneToMany(targetEntity: EntityWorkflowComment::class, mappedBy: 'entityWorkflow', orphanRemoval: true)] | ||||
|     private Collection $comments; | ||||
| @@ -81,10 +52,10 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface | ||||
|     private int $relatedEntityId; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<EntityWorkflowStep> | ||||
|      * @var Collection<int, EntityWorkflowStep> | ||||
|      */ | ||||
|     #[Assert\Valid(traverse: true)] | ||||
|     #[ORM\OneToMany(targetEntity: EntityWorkflowStep::class, mappedBy: 'entityWorkflow', orphanRemoval: true, cascade: ['persist'])] | ||||
|     #[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowStep::class, cascade: ['persist'], orphanRemoval: true)] | ||||
|     #[ORM\OrderBy(['transitionAt' => \Doctrine\Common\Collections\Criteria::ASC, 'id' => 'ASC'])] | ||||
|     private Collection $steps; | ||||
|  | ||||
| @@ -94,14 +65,14 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface | ||||
|     private ?array $stepsChainedCache = null; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: User::class)] | ||||
|     #[ORM\JoinTable(name: 'chill_main_workflow_entity_subscriber_to_final')] | ||||
|     private Collection $subscriberToFinal; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: User::class)] | ||||
|     #[ORM\JoinTable(name: 'chill_main_workflow_entity_subscriber_to_step')] | ||||
| @@ -276,12 +247,16 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface | ||||
|         return $this->steps; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @throws \Exception | ||||
|      */ | ||||
|     public function getStepsChained(): array | ||||
|     { | ||||
|         if (\is_array($this->stepsChainedCache)) { | ||||
|             return $this->stepsChainedCache; | ||||
|         } | ||||
|  | ||||
|         /** @var \ArrayIterator $iterator */ | ||||
|         $iterator = $this->steps->getIterator(); | ||||
|         $current = null; | ||||
|         $steps = []; | ||||
| @@ -442,11 +417,43 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface | ||||
|      * | ||||
|      * @return $this | ||||
|      */ | ||||
|     public function setStep(string $step): self | ||||
|     { | ||||
|     public function setStep( | ||||
|         string $step, | ||||
|         WorkflowTransitionContextDTO $transitionContextDTO, | ||||
|         string $transition, | ||||
|         \DateTimeImmutable $transitionAt, | ||||
|         ?User $byUser = null | ||||
|     ): self { | ||||
|         $previousStep = $this->getCurrentStep(); | ||||
|  | ||||
|         $previousStep | ||||
|             ->setTransitionAfter($transition) | ||||
|             ->setTransitionAt($transitionAt) | ||||
|             ->setTransitionBy($byUser); | ||||
|  | ||||
|         $newStep = new EntityWorkflowStep(); | ||||
|         $newStep->setCurrentStep($step); | ||||
|  | ||||
|         foreach ($transitionContextDTO->futureCcUsers as $user) { | ||||
|             $newStep->addCcUser($user); | ||||
|         } | ||||
|  | ||||
|         foreach ($transitionContextDTO->futureDestUsers as $user) { | ||||
|             $newStep->addDestUser($user); | ||||
|         } | ||||
|  | ||||
|         foreach ($transitionContextDTO->futureDestEmails as $email) { | ||||
|             $newStep->addDestEmail($email); | ||||
|         } | ||||
|  | ||||
|         if (null !== $transitionContextDTO->futureUserSignature) { | ||||
|             new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature); | ||||
|         } else { | ||||
|             foreach ($transitionContextDTO->futurePersonSignatures as $personSignature) { | ||||
|                 new EntityWorkflowStepSignature($newStep, $personSignature); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // copy the freeze | ||||
|         if ($this->isFreeze()) { | ||||
|             $newStep->setFreezeAfter(true); | ||||
|   | ||||
| @@ -0,0 +1,20 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Entity\Workflow; | ||||
|  | ||||
| enum EntityWorkflowSignatureStateEnum: string | ||||
| { | ||||
|     case PENDING = 'pending'; | ||||
|     case SIGNED = 'signed'; | ||||
|     case REJECTED = 'rejected'; | ||||
|     case CANCELED = 'canceled'; | ||||
| } | ||||
| @@ -26,7 +26,7 @@ class EntityWorkflowStep | ||||
|     private string $accessKey; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: User::class)] | ||||
|     #[ORM\JoinTable(name: 'chill_main_workflow_entity_step_cc_user')] | ||||
| @@ -42,19 +42,25 @@ class EntityWorkflowStep | ||||
|     private array $destEmail = []; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: User::class)] | ||||
|     #[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user')] | ||||
|     private Collection $destUser; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection<User> | ||||
|      * @var Collection<int, User> | ||||
|      */ | ||||
|     #[ORM\ManyToMany(targetEntity: User::class)] | ||||
|     #[ORM\JoinTable(name: 'chill_main_workflow_entity_step_user_by_accesskey')] | ||||
|     private Collection $destUserByAccessKey; | ||||
|  | ||||
|     /** | ||||
|      * @var Collection <int, EntityWorkflowStepSignature> | ||||
|      */ | ||||
|     #[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepSignature::class, cascade: ['persist'], orphanRemoval: true)] | ||||
|     private Collection $signatures; | ||||
|  | ||||
|     #[ORM\ManyToOne(targetEntity: EntityWorkflow::class, inversedBy: 'steps')] | ||||
|     private ?EntityWorkflow $entityWorkflow = null; | ||||
|  | ||||
| @@ -97,6 +103,7 @@ class EntityWorkflowStep | ||||
|         $this->ccUser = new ArrayCollection(); | ||||
|         $this->destUser = new ArrayCollection(); | ||||
|         $this->destUserByAccessKey = new ArrayCollection(); | ||||
|         $this->signatures = new ArrayCollection(); | ||||
|         $this->accessKey = bin2hex(openssl_random_pseudo_bytes(32)); | ||||
|     } | ||||
|  | ||||
| @@ -136,6 +143,27 @@ class EntityWorkflowStep | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @internal use @see{EntityWorkflowStepSignature}'s constructor instead | ||||
|      */ | ||||
|     public function addSignature(EntityWorkflowStepSignature $signature): self | ||||
|     { | ||||
|         if (!$this->signatures->contains($signature)) { | ||||
|             $this->signatures[] = $signature; | ||||
|         } | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function removeSignature(EntityWorkflowStepSignature $signature): self | ||||
|     { | ||||
|         if ($this->signatures->contains($signature)) { | ||||
|             $this->signatures->removeElement($signature); | ||||
|         } | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getAccessKey(): string | ||||
|     { | ||||
|         return $this->accessKey; | ||||
| @@ -198,6 +226,14 @@ class EntityWorkflowStep | ||||
|         return $this->entityWorkflow; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return Collection<int, EntityWorkflowStepSignature> | ||||
|      */ | ||||
|     public function getSignatures(): Collection | ||||
|     { | ||||
|         return $this->signatures; | ||||
|     } | ||||
|  | ||||
|     public function getId(): ?int | ||||
|     { | ||||
|         return $this->id; | ||||
|   | ||||
| @@ -0,0 +1,138 @@ | ||||
| <?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\Entity\Workflow; | ||||
|  | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
|  | ||||
| #[ORM\Entity] | ||||
| #[ORM\Table(name: 'chill_main_workflow_entity_step_signature')] | ||||
| class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdateInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|     use TrackUpdateTrait; | ||||
|  | ||||
|     #[ORM\Id] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, unique: true)] | ||||
|     #[ORM\GeneratedValue(strategy: 'AUTO')] | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     #[ORM\ManyToOne(targetEntity: User::class)] | ||||
|     #[ORM\JoinColumn(nullable: true)] | ||||
|     private ?User $userSigner = null; | ||||
|  | ||||
|     #[ORM\ManyToOne(targetEntity: Person::class)] | ||||
|     #[ORM\JoinColumn(nullable: true)] | ||||
|     private ?Person $personSigner = null; | ||||
|  | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 50, nullable: false, enumType: EntityWorkflowSignatureStateEnum::class)] | ||||
|     private EntityWorkflowSignatureStateEnum $state = EntityWorkflowSignatureStateEnum::PENDING; | ||||
|  | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIMETZ_IMMUTABLE, nullable: true, options: ['default' => null])] | ||||
|     private ?\DateTimeImmutable $stateDate = null; | ||||
|  | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] | ||||
|     private array $signatureMetadata = []; | ||||
|  | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: true, options: ['default' => null])] | ||||
|     private ?int $zoneSignatureIndex = null; | ||||
|  | ||||
|     public function __construct( | ||||
|         #[ORM\ManyToOne(targetEntity: EntityWorkflowStep::class, inversedBy: 'signatures')] | ||||
|         private EntityWorkflowStep $step, | ||||
|         User|Person $signer, | ||||
|     ) { | ||||
|         $this->step->addSignature($this); | ||||
|         $this->setSigner($signer); | ||||
|     } | ||||
|  | ||||
|     private function setSigner(User|Person $signer): void | ||||
|     { | ||||
|         if ($signer instanceof User) { | ||||
|             $this->userSigner = $signer; | ||||
|         } else { | ||||
|             $this->personSigner = $signer; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function getId(): ?int | ||||
|     { | ||||
|         return $this->id; | ||||
|     } | ||||
|  | ||||
|     public function getStep(): EntityWorkflowStep | ||||
|     { | ||||
|         return $this->step; | ||||
|     } | ||||
|  | ||||
|     public function getSigner(): User|Person | ||||
|     { | ||||
|         if (null !== $this->userSigner) { | ||||
|             return $this->userSigner; | ||||
|         } | ||||
|  | ||||
|         return $this->personSigner; | ||||
|     } | ||||
|  | ||||
|     public function getSignatureMetadata(): array | ||||
|     { | ||||
|         return $this->signatureMetadata; | ||||
|     } | ||||
|  | ||||
|     public function setSignatureMetadata(array $signatureMetadata): EntityWorkflowStepSignature | ||||
|     { | ||||
|         $this->signatureMetadata = $signatureMetadata; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getState(): EntityWorkflowSignatureStateEnum | ||||
|     { | ||||
|         return $this->state; | ||||
|     } | ||||
|  | ||||
|     public function setState(EntityWorkflowSignatureStateEnum $state): EntityWorkflowStepSignature | ||||
|     { | ||||
|         $this->state = $state; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getStateDate(): ?\DateTimeImmutable | ||||
|     { | ||||
|         return $this->stateDate; | ||||
|     } | ||||
|  | ||||
|     public function setStateDate(?\DateTimeImmutable $stateDate): EntityWorkflowStepSignature | ||||
|     { | ||||
|         $this->stateDate = $stateDate; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getZoneSignatureIndex(): ?int | ||||
|     { | ||||
|         return $this->zoneSignatureIndex; | ||||
|     } | ||||
|  | ||||
|     public function setZoneSignatureIndex(?int $zoneSignatureIndex): EntityWorkflowStepSignature | ||||
|     { | ||||
|         $this->zoneSignatureIndex = $zoneSignatureIndex; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,62 @@ | ||||
| <?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\Form; | ||||
|  | ||||
| use Chill\MainBundle\Form\Type\ChillDateType; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\TextType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| class WorkflowSignatureMetadataType extends AbstractType | ||||
| { | ||||
|     public function __construct(private readonly ParameterBagInterface $parameterBag, private readonly TranslatableStringHelperInterface $translatableStringHelper) {} | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options): void | ||||
|     { | ||||
|         $documentTypeChoices = $this->parameterBag->get('chill_main')['workflow_signature']['base_signer']['document_kinds']; | ||||
|  | ||||
|         $choices = []; | ||||
|  | ||||
|         foreach ($documentTypeChoices as $documentType) { | ||||
|             $labels = []; | ||||
|  | ||||
|             foreach ($documentType['labels'] as $label) { | ||||
|                 $labels[$label['lang']] = $label['label']; | ||||
|             } | ||||
|  | ||||
|             $localizedLabel = $this->translatableStringHelper->localize($labels); | ||||
|             if (null !== $localizedLabel) { | ||||
|                 $choices[$localizedLabel] = $documentType['key']; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $builder | ||||
|             ->add('documentType', ChoiceType::class, [ | ||||
|                 'label' => 'workflow.signature_zone.metadata.docType', | ||||
|                 'expanded' => false, | ||||
|                 'required' => true, | ||||
|                 'choices' => $choices, | ||||
|             ]) | ||||
|             ->add('documentNumber', TextType::class, [ | ||||
|                 'required' => true, | ||||
|                 'label' => 'workflow.signature_zone.metadata.docNumber', | ||||
|             ]) | ||||
|             ->add('expirationDate', ChillDateType::class, [ | ||||
|                 'required' => true, | ||||
|                 'input' => 'datetime_immutable', | ||||
|                 'label' => 'workflow.signature_zone.metadata.docExpiration', | ||||
|             ]); | ||||
|     } | ||||
| } | ||||
| @@ -12,14 +12,13 @@ declare(strict_types=1); | ||||
| namespace Chill\MainBundle\Form; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; | ||||
| use Chill\MainBundle\Form\Type\ChillCollectionType; | ||||
| use Chill\MainBundle\Form\Type\ChillTextareaType; | ||||
| use Chill\MainBundle\Form\Type\PickUserDynamicType; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; | ||||
| use Chill\PersonBundle\Form\Type\PickPersonDynamicType; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\CheckboxType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\EmailType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| @@ -34,169 +33,179 @@ use Symfony\Component\Workflow\Transition; | ||||
|  | ||||
| class WorkflowStepType extends AbstractType | ||||
| { | ||||
|     public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly Registry $registry, private readonly TranslatableStringHelperInterface $translatableStringHelper) {} | ||||
|     public function __construct( | ||||
|         private readonly Registry $registry, | ||||
|         private readonly TranslatableStringHelperInterface $translatableStringHelper | ||||
|     ) {} | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
|     { | ||||
|         /** @var EntityWorkflow $entityWorkflow */ | ||||
|         $entityWorkflow = $options['entity_workflow']; | ||||
|         $handler = $this->entityWorkflowManager->getHandler($entityWorkflow); | ||||
|         $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); | ||||
|         $place = $workflow->getMarking($entityWorkflow); | ||||
|         $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata(array_keys($place->getPlaces())[0]); | ||||
|  | ||||
|         if (true === $options['transition']) { | ||||
|             if (null === $options['entity_workflow']) { | ||||
|                 throw new \LogicException('if transition is true, entity_workflow should be defined'); | ||||
|             } | ||||
|         if (null === $options['entity_workflow']) { | ||||
|             throw new \LogicException('if transition is true, entity_workflow should be defined'); | ||||
|         } | ||||
|  | ||||
|             $transitions = $this->registry | ||||
|                 ->get($options['entity_workflow'], $entityWorkflow->getWorkflowName()) | ||||
|                 ->getEnabledTransitions($entityWorkflow); | ||||
|         $transitions = $this->registry | ||||
|             ->get($options['entity_workflow'], $entityWorkflow->getWorkflowName()) | ||||
|             ->getEnabledTransitions($entityWorkflow); | ||||
|  | ||||
|             $choices = array_combine( | ||||
|                 array_map( | ||||
|                     static fn (Transition $transition) => $transition->getName(), | ||||
|                     $transitions | ||||
|                 ), | ||||
|         $choices = array_combine( | ||||
|             array_map( | ||||
|                 static fn (Transition $transition) => $transition->getName(), | ||||
|                 $transitions | ||||
|             ); | ||||
|             ), | ||||
|             $transitions | ||||
|         ); | ||||
|  | ||||
|             if (\array_key_exists('validationFilterInputLabels', $placeMetadata)) { | ||||
|                 $inputLabels = $placeMetadata['validationFilterInputLabels']; | ||||
|         if (\array_key_exists('validationFilterInputLabels', $placeMetadata)) { | ||||
|             $inputLabels = $placeMetadata['validationFilterInputLabels']; | ||||
|  | ||||
|                 $builder->add('transitionFilter', ChoiceType::class, [ | ||||
|                     'multiple' => false, | ||||
|                     'label' => 'workflow.My decision', | ||||
|                     'choices' => [ | ||||
|                         'forward' => 'forward', | ||||
|                         'backward' => 'backward', | ||||
|                         'neutral' => 'neutral', | ||||
|                     ], | ||||
|                     'choice_label' => fn (string $key) => $this->translatableStringHelper->localize($inputLabels[$key]), | ||||
|                     'choice_attr' => static fn (string $key) => [ | ||||
|                         $key => $key, | ||||
|                     ], | ||||
|                     'mapped' => false, | ||||
|                     'expanded' => true, | ||||
|                     'data' => 'forward', | ||||
|                 ]); | ||||
|             } | ||||
|  | ||||
|             $builder | ||||
|                 ->add('transition', ChoiceType::class, [ | ||||
|                     'label' => 'workflow.Next step', | ||||
|                     'mapped' => false, | ||||
|                     'multiple' => false, | ||||
|                     'expanded' => true, | ||||
|                     'choices' => $choices, | ||||
|                     'constraints' => [new NotNull()], | ||||
|                     'choice_label' => function (Transition $transition) use ($workflow) { | ||||
|                         $meta = $workflow->getMetadataStore()->getTransitionMetadata($transition); | ||||
|  | ||||
|                         if (\array_key_exists('label', $meta)) { | ||||
|                             return $this->translatableStringHelper->localize($meta['label']); | ||||
|                         } | ||||
|  | ||||
|                         return $transition->getName(); | ||||
|                     }, | ||||
|                     'choice_attr' => static function (Transition $transition) use ($workflow) { | ||||
|                         $toFinal = true; | ||||
|                         $isForward = 'neutral'; | ||||
|  | ||||
|                         $metadata = $workflow->getMetadataStore()->getTransitionMetadata($transition); | ||||
|  | ||||
|                         if (\array_key_exists('isForward', $metadata)) { | ||||
|                             if ($metadata['isForward']) { | ||||
|                                 $isForward = 'forward'; | ||||
|                             } else { | ||||
|                                 $isForward = 'backward'; | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         foreach ($transition->getTos() as $to) { | ||||
|                             $meta = $workflow->getMetadataStore()->getPlaceMetadata($to); | ||||
|  | ||||
|                             if ( | ||||
|                                 !\array_key_exists('isFinal', $meta) || false === $meta['isFinal'] | ||||
|                             ) { | ||||
|                                 $toFinal = false; | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         return [ | ||||
|                             'data-is-transition' => 'data-is-transition', | ||||
|                             'data-to-final' => $toFinal ? '1' : '0', | ||||
|                             'data-is-forward' => $isForward, | ||||
|                         ]; | ||||
|                     }, | ||||
|                 ]) | ||||
|                 ->add('future_dest_users', PickUserDynamicType::class, [ | ||||
|                     'label' => 'workflow.dest for next steps', | ||||
|                     'multiple' => true, | ||||
|                     'mapped' => false, | ||||
|                     'suggested' => $options['suggested_users'], | ||||
|                 ]) | ||||
|                 ->add('future_cc_users', PickUserDynamicType::class, [ | ||||
|                     'label' => 'workflow.cc for next steps', | ||||
|                     'multiple' => true, | ||||
|                     'mapped' => false, | ||||
|                     'required' => false, | ||||
|                     'suggested' => $options['suggested_users'], | ||||
|                 ]) | ||||
|                 ->add('future_dest_emails', ChillCollectionType::class, [ | ||||
|                     'label' => 'workflow.dest by email', | ||||
|                     'help' => 'workflow.dest by email help', | ||||
|                     'mapped' => false, | ||||
|                     'allow_add' => true, | ||||
|                     'entry_type' => EmailType::class, | ||||
|                     'button_add_label' => 'workflow.Add an email', | ||||
|                     'button_remove_label' => 'workflow.Remove an email', | ||||
|                     'empty_collection_explain' => 'workflow.Any email', | ||||
|                     'entry_options' => [ | ||||
|                         'constraints' => [ | ||||
|                             new NotNull(), new NotBlank(), new Email(), | ||||
|                         ], | ||||
|                         'label' => 'Email', | ||||
|                     ], | ||||
|                 ]); | ||||
|             $builder->add('transitionFilter', ChoiceType::class, [ | ||||
|                 'multiple' => false, | ||||
|                 'label' => 'workflow.My decision', | ||||
|                 'choices' => [ | ||||
|                     'forward' => 'forward', | ||||
|                     'backward' => 'backward', | ||||
|                     'neutral' => 'neutral', | ||||
|                 ], | ||||
|                 'choice_label' => fn (string $key) => $this->translatableStringHelper->localize($inputLabels[$key]), | ||||
|                 'choice_attr' => static fn (string $key) => [ | ||||
|                     $key => $key, | ||||
|                 ], | ||||
|                 'mapped' => false, | ||||
|                 'expanded' => true, | ||||
|                 'data' => 'forward', | ||||
|             ]); | ||||
|         } | ||||
|  | ||||
|         if ( | ||||
|             $handler->supportsFreeze($entityWorkflow) | ||||
|             && !$entityWorkflow->isFreeze() | ||||
|         ) { | ||||
|             $builder | ||||
|                 ->add('freezeAfter', CheckboxType::class, [ | ||||
|                     'required' => false, | ||||
|                     'label' => 'workflow.Freeze', | ||||
|                     'help' => 'workflow.The associated element will be freezed', | ||||
|                 ]); | ||||
|         } | ||||
|         $builder | ||||
|             ->add('transition', ChoiceType::class, [ | ||||
|                 'label' => 'workflow.Next step', | ||||
|                 'mapped' => false, | ||||
|                 'multiple' => false, | ||||
|                 'expanded' => true, | ||||
|                 'choices' => $choices, | ||||
|                 'constraints' => [new NotNull()], | ||||
|                 'choice_label' => function (Transition $transition) use ($workflow) { | ||||
|                     $meta = $workflow->getMetadataStore()->getTransitionMetadata($transition); | ||||
|  | ||||
|                     if (\array_key_exists('label', $meta)) { | ||||
|                         return $this->translatableStringHelper->localize($meta['label']); | ||||
|                     } | ||||
|  | ||||
|                     return $transition->getName(); | ||||
|                 }, | ||||
|                 'choice_attr' => static function (Transition $transition) use ($workflow) { | ||||
|                     $toFinal = true; | ||||
|                     $isForward = 'neutral'; | ||||
|                     $isSignature = []; | ||||
|  | ||||
|                     $metadata = $workflow->getMetadataStore()->getTransitionMetadata($transition); | ||||
|  | ||||
|                     if (\array_key_exists('isForward', $metadata)) { | ||||
|                         if ($metadata['isForward']) { | ||||
|                             $isForward = 'forward'; | ||||
|                         } else { | ||||
|                             $isForward = 'backward'; | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     foreach ($transition->getTos() as $to) { | ||||
|                         $meta = $workflow->getMetadataStore()->getPlaceMetadata($to); | ||||
|  | ||||
|                         if ( | ||||
|                             !\array_key_exists('isFinal', $meta) || false === $meta['isFinal'] | ||||
|                         ) { | ||||
|                             $toFinal = false; | ||||
|                         } | ||||
|  | ||||
|                         if (\array_key_exists('isSignature', $meta)) { | ||||
|                             $isSignature = $meta['isSignature']; | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     return [ | ||||
|                         'data-is-transition' => 'data-is-transition', | ||||
|                         'data-to-final' => $toFinal ? '1' : '0', | ||||
|                         'data-is-forward' => $isForward, | ||||
|                         'data-is-signature' => json_encode($isSignature), | ||||
|                     ]; | ||||
|                 }, | ||||
|             ]) | ||||
|             ->add('isPersonOrUserSignature', ChoiceType::class, [ | ||||
|                 'mapped' => false, | ||||
|                 'multiple' => false, | ||||
|                 'expanded' => true, | ||||
|                 'label' => 'workflow.signature_zone.type of signature', | ||||
|                 'choices' => [ | ||||
|                     'workflow.signature_zone.persons' => 'person', | ||||
|                     'workflow.signature_zone.user' => 'user', | ||||
|                 ], | ||||
|             ]) | ||||
|             ->add('futurePersonSignatures', PickPersonDynamicType::class, [ | ||||
|                 'label' => 'workflow.signature_zone.person signatures', | ||||
|                 'multiple' => true, | ||||
|                 'empty_data' => '[]', | ||||
|             ]) | ||||
|             ->add('futureUserSignature', PickUserDynamicType::class, [ | ||||
|                 'label' => 'workflow.signature_zone.user signature', | ||||
|                 'multiple' => false, | ||||
|             ]) | ||||
|             ->add('futureDestUsers', PickUserDynamicType::class, [ | ||||
|                 'label' => 'workflow.dest for next steps', | ||||
|                 'multiple' => true, | ||||
|                 'empty_data' => '[]', | ||||
|                 'suggested' => $options['suggested_users'], | ||||
|             ]) | ||||
|             ->add('futureCcUsers', PickUserDynamicType::class, [ | ||||
|                 'label' => 'workflow.cc for next steps', | ||||
|                 'multiple' => true, | ||||
|                 'required' => false, | ||||
|                 'suggested' => $options['suggested_users'], | ||||
|                 'empty_data' => '[]', | ||||
|                 'attr' => ['class' => 'future-cc-users'], | ||||
|             ]) | ||||
|             ->add('futureDestEmails', ChillCollectionType::class, [ | ||||
|                 'label' => 'workflow.dest by email', | ||||
|                 'help' => 'workflow.dest by email help', | ||||
|                 'allow_add' => true, | ||||
|                 'entry_type' => EmailType::class, | ||||
|                 'button_add_label' => 'workflow.Add an email', | ||||
|                 'button_remove_label' => 'workflow.Remove an email', | ||||
|                 'empty_collection_explain' => 'workflow.Any email', | ||||
|                 'entry_options' => [ | ||||
|                     'constraints' => [ | ||||
|                         new NotNull(), new NotBlank(), new Email(), | ||||
|                     ], | ||||
|                     'label' => 'Email', | ||||
|                 ], | ||||
|             ]); | ||||
|  | ||||
|         $builder | ||||
|             ->add('comment', ChillTextareaType::class, [ | ||||
|                 'required' => false, | ||||
|                 'label' => 'Comment', | ||||
|                 'empty_data' => '', | ||||
|             ]); | ||||
|     } | ||||
|  | ||||
|     public function configureOptions(OptionsResolver $resolver) | ||||
|     { | ||||
|         $resolver | ||||
|             ->setDefined('class') | ||||
|             ->setRequired('transition') | ||||
|             ->setAllowedTypes('transition', 'bool') | ||||
|             ->setDefault('data_class', WorkflowTransitionContextDTO::class) | ||||
|             ->setRequired('entity_workflow') | ||||
|             ->setAllowedTypes('entity_workflow', EntityWorkflow::class) | ||||
|             ->setDefault('suggested_users', []) | ||||
|             ->setDefault('constraints', [ | ||||
|                 new Callback( | ||||
|                     function ($step, ExecutionContextInterface $context, $payload) { | ||||
|                         /** @var EntityWorkflowStep $step */ | ||||
|                         $form = $context->getObject(); | ||||
|                         $workflow = $this->registry->get($step->getEntityWorkflow(), $step->getEntityWorkflow()->getWorkflowName()); | ||||
|                         $transition = $form['transition']->getData(); | ||||
|                     function (WorkflowTransitionContextDTO $step, ExecutionContextInterface $context, $payload) { | ||||
|                         $workflow = $this->registry->get($step->entityWorkflow, $step->entityWorkflow->getWorkflowName()); | ||||
|                         $transition = $step->transition; | ||||
|                         $toFinal = true; | ||||
|  | ||||
|                         if (null === $transition) { | ||||
| @@ -212,8 +221,8 @@ class WorkflowStepType extends AbstractType | ||||
|                                     $toFinal = false; | ||||
|                                 } | ||||
|                             } | ||||
|                             $destUsers = $form['future_dest_users']->getData(); | ||||
|                             $destEmails = $form['future_dest_emails']->getData(); | ||||
|                             $destUsers = $step->futureDestUsers; | ||||
|                             $destEmails = $step->futureDestEmails; | ||||
|  | ||||
|                             if (!$toFinal && [] === $destUsers && [] === $destEmails) { | ||||
|                                 $context | ||||
| @@ -224,20 +233,6 @@ class WorkflowStepType extends AbstractType | ||||
|                         } | ||||
|                     } | ||||
|                 ), | ||||
|                 new Callback( | ||||
|                     function ($step, ExecutionContextInterface $context, $payload) { | ||||
|                         $form = $context->getObject(); | ||||
|  | ||||
|                         foreach ($form->get('future_dest_users')->getData() as $u) { | ||||
|                             if (in_array($u, $form->get('future_cc_users')->getData(), true)) { | ||||
|                                 $context | ||||
|                                     ->buildViolation('workflow.The user in cc cannot be a dest user in the same workflow step') | ||||
|                                     ->atPath('ccUsers') | ||||
|                                     ->addViolation(); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 ), | ||||
|             ]); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -99,6 +99,24 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return list<EntityWorkflow> | ||||
|      */ | ||||
|     public function findByRelatedEntity($entityClass, $relatedEntityId): array | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('w'); | ||||
|  | ||||
|         $query = $qb->where( | ||||
|             $qb->expr()->andX( | ||||
|                 $qb->expr()->eq('w.relatedEntityClass', ':entity_class'), | ||||
|                 $qb->expr()->eq('w.relatedEntityId', ':entity_id'), | ||||
|             ) | ||||
|         )->setParameter('entity_class', $entityClass) | ||||
|             ->setParameter('entity_id', $relatedEntityId); | ||||
|  | ||||
|         return $query->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param mixed|null $limit | ||||
|      * @param mixed|null $offset | ||||
|   | ||||
| @@ -0,0 +1,54 @@ | ||||
| <?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\Repository\Workflow; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| /** | ||||
|  * @template-implements ObjectRepository<EntityWorkflowStepSignature> | ||||
|  */ | ||||
| class EntityWorkflowStepSignatureRepository implements ObjectRepository | ||||
| { | ||||
|     private readonly \Doctrine\ORM\EntityRepository $repository; | ||||
|  | ||||
|     public function __construct(EntityManagerInterface $entityManager) | ||||
|     { | ||||
|         $this->repository = $entityManager->getRepository(EntityWorkflowStepSignature::class); | ||||
|     } | ||||
|  | ||||
|     public function find($id): ?EntityWorkflowStepSignature | ||||
|     { | ||||
|         return $this->repository->find($id); | ||||
|     } | ||||
|  | ||||
|     public function findAll(): array | ||||
|     { | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array | ||||
|     { | ||||
|         return $this->findBy($criteria, $orderBy, $limit, $offset); | ||||
|     } | ||||
|  | ||||
|     public function findOneBy(array $criteria): ?EntityWorkflowStepSignature | ||||
|     { | ||||
|         return $this->findOneBy($criteria); | ||||
|     } | ||||
|  | ||||
|     public function getClassName(): string | ||||
|     { | ||||
|         return EntityWorkflowStepSignature::class; | ||||
|     } | ||||
| } | ||||
| @@ -53,14 +53,17 @@ export const handleAdd = (button: any): void => { | ||||
|     let | ||||
|         empty_explain: HTMLLIElement | null = collection.querySelector('li[data-collection-empty-explain]'), | ||||
|         entry = document.createElement('li'), | ||||
|         counter = collection.childNodes.length + 1, | ||||
|         content = prototype.replace(new RegExp('__name__', 'g'), counter.toString()), | ||||
|         counter = collection.querySelectorAll('li.entry').length, // Updated counter logic | ||||
|         content = prototype.replace(/__name__/g, counter.toString()), | ||||
|         event = new CustomEvent('collection-add-entry', {detail: new CollectionEventPayload(collection, entry)}); | ||||
|  | ||||
|     console.log(counter) | ||||
|     console.log(content) | ||||
|  | ||||
|     entry.innerHTML = content; | ||||
|     entry.classList.add('entry'); | ||||
|  | ||||
|     if ("dataCollectionRegular" in collection.dataset) { | ||||
|     if ("collectionRegular" in collection.dataset) { | ||||
|         initializeRemove(collection, entry); | ||||
|         if (empty_explain !== null) { | ||||
|             empty_explain.remove(); | ||||
|   | ||||
| @@ -159,3 +159,5 @@ document.addEventListener('DOMContentLoaded', function(e) { | ||||
|     loadDynamicPicker(document) | ||||
| }) | ||||
|  | ||||
| window.loadDynamicPicker = loadDynamicPicker; | ||||
|  | ||||
|   | ||||
| @@ -4,38 +4,124 @@ window.addEventListener('DOMContentLoaded', function() { | ||||
|     let | ||||
|         divTransitions = document.querySelector('#transitions'), | ||||
|         futureDestUsersContainer = document.querySelector('#futureDests') | ||||
|     ; | ||||
|  | ||||
|     if (null !== divTransitions) { | ||||
|         new ShowHide({ | ||||
|             load_event: null, | ||||
|             froms: [divTransitions], | ||||
|             container: [futureDestUsersContainer], | ||||
|             test: function(divs, arg2, arg3) { | ||||
|                 for (let div of divs) { | ||||
|                     for (let input of div.querySelectorAll('input')) { | ||||
|                         if (input.checked) { | ||||
|                             if (input.dataset.toFinal === "1") { | ||||
|                                 return false; | ||||
|                             } else { | ||||
|                                 return true; | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 return true; | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
|         personSignatureField = document.querySelector('#person-signature-field'); | ||||
|         userSignatureField = document.querySelector('#user-signature-field'); | ||||
|         signatureTypeChoices = document.querySelector('#signature-type-choice'); | ||||
|         personChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_0'); | ||||
|         userChoice = document.querySelector('#workflow_step_isPersonOrUserSignature_1'); | ||||
|         signatureZone = document.querySelector('#signature-zone'); | ||||
|   ; | ||||
|  | ||||
|     let | ||||
|         transitionFilterContainer = document.querySelector('#transitionFilter'), | ||||
|         transitions = document.querySelector('#transitions') | ||||
|       transitionFilterContainer = document.querySelector('#transitionFilter'), | ||||
|       transitionsContainer = document.querySelector('#transitions') | ||||
|     ; | ||||
|  | ||||
|     // ShowHide instance for signatureTypeChoices. This should always be present in the DOM and we toggle visibility. | ||||
|     // The field is not mapped and so not submitted with the form. Without it's presence upon DOM loading other show hides do not function well. | ||||
|     signatureTypeChoices.style.display = 'none'; | ||||
|  | ||||
|  | ||||
|     // ShowHide instance for future dest users | ||||
|     new ShowHide({ | ||||
|       debug: false, | ||||
|       load_event: null, | ||||
|       froms: [divTransitions], | ||||
|       container: [futureDestUsersContainer], | ||||
|       test: function(froms, event) { | ||||
|         for (let transition of froms) { | ||||
|           for (let input of transition.querySelectorAll('input')) { | ||||
|             if (input.checked) { | ||||
|               const inputData = JSON.parse(input.getAttribute('data-is-signature')) | ||||
|               if (inputData.includes('person') || inputData.includes('user')) { | ||||
|                 signatureTypeChoices.style.display = ''; | ||||
|                 return false; | ||||
|               } else { | ||||
|                 personChoice.checked = false | ||||
|                 userChoice.checked = false | ||||
|  | ||||
|                 signatureTypeChoices.style.display = 'none'; | ||||
|                 return true; | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|         return false; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // ShowHide signature zone | ||||
|     new ShowHide({ | ||||
|       debug: false, | ||||
|       load_event: null, | ||||
|       froms: [divTransitions], | ||||
|       container: [signatureZone], | ||||
|       test: function(froms, event) { | ||||
|         for (let transition of froms) { | ||||
|           for (let input of transition.querySelectorAll('input')) { | ||||
|             if (input.checked) { | ||||
|               const inputData = JSON.parse(input.getAttribute('data-is-signature')) | ||||
|               if (inputData.includes('person') || inputData.includes('user')) { | ||||
|                 signatureTypeChoices.style.display = ''; | ||||
|                 return true; | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|         return false; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // ShowHides for personSignatureField or userSignatureField within signature zone | ||||
|     new ShowHide({ | ||||
|       debug: false, | ||||
|       froms: [signatureTypeChoices], | ||||
|       container: [personSignatureField], | ||||
|       test: function(froms, event) { | ||||
|         for (let container of froms) { | ||||
|           return personChoice.checked; | ||||
|         } | ||||
|         return false; | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     new ShowHide({ | ||||
|       debug: false, | ||||
|       froms: [signatureTypeChoices], | ||||
|       container: [userSignatureField], | ||||
|       test: function(froms, event) { | ||||
|         for (let container of froms) { | ||||
|           return userChoice.checked; | ||||
|         } | ||||
|         return false; | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     if (null !== divTransitions) { | ||||
|       new ShowHide({ | ||||
|           load_event: null, | ||||
|           froms: [divTransitions], | ||||
|           container: [futureDestUsersContainer], | ||||
|           test: function(divs, arg2, arg3) { | ||||
|             for (let div of divs) { | ||||
|                   for (let input of div.querySelectorAll('input')) { | ||||
|                       if (input.checked) { | ||||
|                           if (input.dataset.toFinal === "1") { | ||||
|                               return false; | ||||
|                           } else { | ||||
|                               return true; | ||||
|                           } | ||||
|                       } | ||||
|                   } | ||||
|               } | ||||
|  | ||||
|               return true; | ||||
|           }, | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     if (null !== transitionFilterContainer) { | ||||
|         transitions.querySelectorAll('.form-check').forEach(function(row) { | ||||
|         transitionsContainer.querySelectorAll('.form-check').forEach(function(row) { | ||||
|  | ||||
|             const isForward = row.querySelector('input').dataset.isForward; | ||||
|  | ||||
| @@ -66,5 +152,4 @@ window.addEventListener('DOMContentLoaded', function() { | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
| }); | ||||
|   | ||||
| @@ -58,17 +58,34 @@ | ||||
|         {{ form_row(transition_form.transition) }} | ||||
|     </div> | ||||
|  | ||||
|     {% if transition_form.freezeAfter is defined %} | ||||
|         {{ form_row(transition_form.freezeAfter) }} | ||||
|     {% endif %} | ||||
|     <div id="signature-zone"> | ||||
|         <div id="signature-type-choice"> | ||||
|             {{ form_row(transition_form.isPersonOrUserSignature) }} | ||||
|             {{ form_errors(transition_form.isPersonOrUserSignature) }} | ||||
|         </div> | ||||
|         <div id="user-signature-field"> | ||||
|             {{ form_row(transition_form.futureUserSignature) }} | ||||
|             {{ form_errors(transition_form.futureUserSignature) }} | ||||
|         </div> | ||||
|         <div id="person-signature-field"> | ||||
|             {{ form_row(transition_form.futurePersonSignatures) }} | ||||
|             {{ form_errors(transition_form.futurePersonSignatures) }} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div id="futureDests"> | ||||
|         {{ form_row(transition_form.future_dest_users) }} | ||||
|  | ||||
|         {{ form_row(transition_form.future_cc_users) }} | ||||
|  | ||||
|         {{ form_row(transition_form.future_dest_emails) }} | ||||
|         {{ form_errors(transition_form.future_dest_users) }} | ||||
|         <div id="future-dest-users"> | ||||
|         {{ form_row(transition_form.futureDestUsers) }} | ||||
|         {{ form_errors(transition_form.futureDestUsers) }} | ||||
|         </div> | ||||
|         <div id="future-cc-users"> | ||||
|         {{ form_row(transition_form.futureCcUsers) }} | ||||
|         {{ form_errors(transition_form.futureCcUsers) }} | ||||
|         </div> | ||||
|         <div id="future-dest-emails"> | ||||
|         {{ form_row(transition_form.futureDestEmails) }} | ||||
|         {{ form_errors(transition_form.futureDestEmails) }} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <p>{{ form_label(transition_form.comment) }}</p> | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user