mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 17:28:23 +00:00 
			
		
		
		
	Compare commits
	
		
			36 Commits
		
	
	
		
			v4.4.2
			...
			449-scope-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9db1a960db | |||
| bf38ec22c9 | |||
| 9c2abb2dfa | |||
| 94744b9542 | |||
| f42bb498e4 | |||
| 01889ac671 | |||
| 62e5842311 | |||
| 8ad6f397a8 | |||
| d713704633 | |||
| b1fa9242a0 | |||
| 6ac554f93a | |||
| 372d8e5825 | |||
| 10f05e5559 | |||
| ddb2a65419 | |||
| 8d40a8089f | |||
| e1bf4a24d2 | |||
| 208a378185 | |||
| 9089c8959b | |||
| 1b9b581c31 | |||
| aa1abe4c88 | |||
| d82c9cc9a7 | |||
| a7e3b1c5d2 | |||
| 84cf11933d | |||
| bc2fbee5c6 | |||
| ebd10ca522 | |||
| d3a31be412 | |||
| d159a82f88 | |||
| c2d9c73fd4 | |||
| 0d6d15fcf7 | |||
| f9ad96c78b | |||
| fcc9529a20 | |||
| 955cb817c4 | |||
| 823f9546b9 | |||
| be39fa16e7 | |||
| c8bb7575e7 | |||
|  | 80a3734171 | 
							
								
								
									
										7
									
								
								.changes/unreleased/DX-20251027-150053.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.changes/unreleased/DX-20251027-150053.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| kind: DX | ||||
| body: | | ||||
|     Send notifications log to dedicated channel, if it exists | ||||
| time: 2025-10-27T15:00:53.309372316+01:00 | ||||
| custom: | ||||
|     Issue: "" | ||||
|     SchemaChange: No schema change | ||||
							
								
								
									
										6
									
								
								.changes/unreleased/UX-20251030-153126.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.changes/unreleased/UX-20251030-153126.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| kind: UX | ||||
| body: Remove the label if there is only one scope and no scope picking field is displayed. | ||||
| time: 2025-10-30T15:31:26.807444365+01:00 | ||||
| custom: | ||||
|     Issue: "449" | ||||
|     SchemaChange: No schema change | ||||
							
								
								
									
										13
									
								
								.changes/v4.5.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								.changes/v4.5.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| ## v4.5.0 - 2025-10-03 | ||||
| ### Feature | ||||
| * Only allow delete of attachment on workflows that are not final    | ||||
| * Move up signature buttons on index workflow page for easier access    | ||||
| * Filter out document from attachment list if it is the same as the workflow document    | ||||
| * Block edition on attached document on workflow, if the workflow is finalized or sent external    | ||||
| * Convert workflow's attached document to pdf while sending them external    | ||||
| * After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition    | ||||
| ### Fixed | ||||
| * ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance    | ||||
| * Fix permissions on storedObject which are subject by a workflow    | ||||
| ### DX | ||||
| * Introduce a WaitingScreen component to display a waiting screen    | ||||
							
								
								
									
										4
									
								
								.changes/v4.5.1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.changes/v4.5.1.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| ## v4.5.1 - 2025-10-03 | ||||
| ### Fixed | ||||
| * Add missing javascript dependency    | ||||
| * Add exception handling for conversion of attachment on sending external, when documens are already in pdf    | ||||
							
								
								
									
										14
									
								
								.changes/v4.6.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								.changes/v4.6.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| ## v4.6.0 - 2025-10-15 | ||||
| ### Feature | ||||
| * ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed    | ||||
| * ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"    | ||||
| ### Fixed | ||||
| * Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present    | ||||
| * Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted    | ||||
| * ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists    | ||||
| * Fix loading of social issues and social actions within vue component    | ||||
| * ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames    | ||||
|  | ||||
|   **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed | ||||
| * [workflow] take permissions into account to delete the workflow attachment    | ||||
| * ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid    | ||||
							
								
								
									
										3
									
								
								.changes/v4.6.1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.changes/v4.6.1.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ## v4.6.1 - 2025-10-27 | ||||
| ### Fixed | ||||
| * Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php    | ||||
| @@ -240,9 +240,6 @@ The tests are run from the project's root (not from the bundle's root). | ||||
| # Run all tests | ||||
| vendor/bin/phpunit | ||||
|  | ||||
| # Run tests for a specific bundle | ||||
| vendor/bin/phpunit --testsuite NameBundle | ||||
|  | ||||
| # Run a specific test file | ||||
| vendor/bin/phpunit path/to/TestFile.php | ||||
|  | ||||
| @@ -250,6 +247,9 @@ vendor/bin/phpunit path/to/TestFile.php | ||||
| vendor/bin/phpunit --filter methodName path/to/TestFile.php | ||||
| ``` | ||||
|  | ||||
| When writing tests, only test specific files. Do not run all tests or the full | ||||
| test suite. | ||||
|  | ||||
| #### Test Structure | ||||
|  | ||||
| Tests are organized by bundle and follow the same structure as the bundle itself: | ||||
|   | ||||
							
								
								
									
										38
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -6,6 +6,44 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), | ||||
| and is generated by [Changie](https://github.com/miniscruff/changie). | ||||
|  | ||||
|  | ||||
| ## v4.6.1 - 2025-10-27 | ||||
| ### Fixed | ||||
| * Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php    | ||||
|  | ||||
| ## v4.6.0 - 2025-10-15 | ||||
| ### Feature | ||||
| * ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed    | ||||
| * ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"    | ||||
| ### Fixed | ||||
| * Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present    | ||||
| * Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted    | ||||
| * ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists    | ||||
| * Fix loading of social issues and social actions within vue component    | ||||
| * ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames    | ||||
|  | ||||
|   **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed | ||||
| * [workflow] take permissions into account to delete the workflow attachment    | ||||
| * ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid    | ||||
|  | ||||
| ## v4.5.1 - 2025-10-03 | ||||
| ### Fixed | ||||
| * Add missing javascript dependency    | ||||
| * Add exception handling for conversion of attachment on sending external, when documens are already in pdf    | ||||
|  | ||||
| ## v4.5.0 - 2025-10-03 | ||||
| ### Feature | ||||
| * Only allow delete of attachment on workflows that are not final    | ||||
| * Move up signature buttons on index workflow page for easier access    | ||||
| * Filter out document from attachment list if it is the same as the workflow document    | ||||
| * Block edition on attached document on workflow, if the workflow is finalized or sent external    | ||||
| * Convert workflow's attached document to pdf while sending them external    | ||||
| * After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition    | ||||
| ### Fixed | ||||
| * ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance    | ||||
| * Fix permissions on storedObject which are subject by a workflow    | ||||
| ### DX | ||||
| * Introduce a WaitingScreen component to display a waiting screen    | ||||
|  | ||||
| ## v4.4.2 - 2025-09-12 | ||||
| ### Fixed | ||||
| * Fix document generation and workflow generation do not work on accompanying period work documents    | ||||
|   | ||||
| @@ -14,7 +14,7 @@ | ||||
|         "ext-openssl": "*", | ||||
|         "ext-redis": "*", | ||||
|         "ext-zlib": "*", | ||||
|         "champs-libres/wopi-bundle": "dev-master@dev", | ||||
|         "champs-libres/wopi-bundle": "dev-master#1be045ee95310d2037683859ecefdbf3a10f7be6 as 0.4.x-dev", | ||||
|         "champs-libres/wopi-lib": "dev-master@dev", | ||||
|         "doctrine/data-fixtures": "^1.8", | ||||
|         "doctrine/doctrine-bundle": "^2.1", | ||||
|   | ||||
| @@ -1,6 +1,13 @@ | ||||
| chill_main: | ||||
|     available_languages: [ '%env(resolve:LOCALE)%', 'en' ] | ||||
|     available_countries: ['BE', 'FR'] | ||||
|     top_banner: | ||||
|         visible: false | ||||
|         text: | ||||
|             fr: 'Vous travaillez actuellement avec la version de PRÉ-PRODUCTION.' | ||||
|             nl: 'Je werkt momenteel in de PRE-PRODUCTIE versie' | ||||
|         color: '#353535' | ||||
|         background_color: '#d8bb48' | ||||
|     notifications: | ||||
|         from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%' | ||||
|         from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%' | ||||
|   | ||||
| @@ -55,6 +55,7 @@ | ||||
|     "@tsconfig/node20": "^20.1.4", | ||||
|     "@types/dompurify": "^3.0.5", | ||||
|     "@types/leaflet": "^1.9.3", | ||||
|     "@vueuse/core": "^13.9.0", | ||||
|     "bootstrap-icons": "^1.11.3", | ||||
|     "dropzone": "^5.7.6", | ||||
|     "es6-promise": "^4.2.8", | ||||
|   | ||||
| @@ -90,7 +90,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return []; | ||||
|         return [ | ||||
|             'reasons' => [], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function describeAction($data, ExportGenerationContext $context): string|\Symfony\Contracts\Translation\TranslatableInterface|array | ||||
|   | ||||
| @@ -42,6 +42,8 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void | ||||
|     { | ||||
|         error_log('alterQuery called with data: '.json_encode(array_keys($data))); | ||||
|  | ||||
|         // create a subquery for activity | ||||
|         $sqb = $qb->getEntityManager()->createQueryBuilder(); | ||||
|         $sqb->select('1') | ||||
| @@ -59,7 +61,6 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|         if (\in_array('activity', $qb->getAllAliases(), true)) { | ||||
|             $sqb->andWhere('activity_person_having_activity.id = activity.id'); | ||||
|         } | ||||
|  | ||||
|         if (isset($data['reasons']) && [] !== $data['reasons']) { | ||||
|             // add clause activity reason | ||||
|             $sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity'); | ||||
| @@ -124,12 +125,38 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|  | ||||
|     public function normalizeFormData(array $formData): array | ||||
|     { | ||||
|         return ['date_from_rolling' => $formData['date_from_rolling']->normalize(), 'date_to_rolling' => $formData['date_to_rolling']->normalize()]; | ||||
|         $normalized = [ | ||||
|             'date_from_rolling' => $formData['date_from_rolling']->normalize(), | ||||
|             'date_to_rolling' => $formData['date_to_rolling']->normalize(), | ||||
|             'reasons' => [], | ||||
|         ]; | ||||
|  | ||||
|         if (isset($formData['reasons']) && [] !== $formData['reasons']) { | ||||
|             $normalized['reasons'] = array_map( | ||||
|                 fn (ActivityReason $reason) => $reason->getId(), | ||||
|                 $formData['reasons'] | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return $normalized; | ||||
|     } | ||||
|  | ||||
|     public function denormalizeFormData(array $formData, int $fromVersion): array | ||||
|     { | ||||
|         return ['date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), 'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling'])]; | ||||
|         $denormalized = [ | ||||
|             'date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), | ||||
|             'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling']), | ||||
|             'reasons' => [], | ||||
|         ]; | ||||
|  | ||||
|         if (isset($formData['reasons']) && [] !== $formData['reasons']) { | ||||
|             $denormalized['reasons'] = array_map( | ||||
|                 fn ($id) => $this->activityReasonRepository->find($id), | ||||
|                 $formData['reasons'] | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return $denormalized; | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
| @@ -143,10 +170,12 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|  | ||||
|     public function describeAction($data, ExportGenerationContext $context): array | ||||
|     { | ||||
|         $reasons = $data['reasons'] ?? []; | ||||
|  | ||||
|         return [ | ||||
|             [] === $data['reasons'] ? | ||||
|                 'export.filter.person_between_dates.describe_action_with_no_subject' | ||||
|                 : 'export.filter.person_between_dates.describe_action_with_subject', | ||||
|             [] === $reasons ? | ||||
|                 'export.filter.activity.describe_action_with_no_subject' | ||||
|                 : 'export.filter.activity.describe_action_with_subject', | ||||
|             [ | ||||
|                 'date_from' => $this->rollingDateConverter->convert($data['date_from_rolling']), | ||||
|                 'date_to' => $this->rollingDateConverter->convert($data['date_to_rolling']), | ||||
| @@ -154,7 +183,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|                     ', ', | ||||
|                     array_map( | ||||
|                         fn (ActivityReason $r): string => '"'.$this->translatableStringHelper->localize($r->getName()).'"', | ||||
|                         $data['reasons'] | ||||
|                         $reasons | ||||
|                     ) | ||||
|                 ), | ||||
|             ], | ||||
| @@ -168,6 +197,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|  | ||||
|     public function validateForm($data, ExecutionContextInterface $context): void | ||||
|     { | ||||
|         error_log('validateForm called with data: '.json_encode(array_keys($data))); | ||||
|         if ($this->rollingDateConverter->convert($data['date_from_rolling']) | ||||
|             >= $this->rollingDateConverter->convert($data['date_to_rolling'])) { | ||||
|             $context->buildViolation('export.filter.activity.person_between_dates.date mismatch') | ||||
|   | ||||
| @@ -18,6 +18,7 @@ use Chill\ActivityBundle\Security\Authorization\ActivityVoter; | ||||
| use Chill\DocStoreBundle\Form\CollectionStoredObjectType; | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Location; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Form\Type\ChillDateType; | ||||
| use Chill\MainBundle\Form\Type\CommentType; | ||||
| @@ -47,6 +48,7 @@ use Symfony\Component\Form\FormEvent; | ||||
| use Symfony\Component\Form\FormEvents; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class ActivityType extends AbstractType | ||||
| { | ||||
| @@ -60,6 +62,7 @@ class ActivityType extends AbstractType | ||||
|         protected array $timeChoices, | ||||
|         protected SocialIssueRender $socialIssueRender, | ||||
|         protected SocialActionRender $socialActionRender, | ||||
|         private readonly Security $security, | ||||
|     ) { | ||||
|         if (!$tokenStorage->getToken()->getUser() instanceof User) { | ||||
|             throw new \RuntimeException('you should have a valid user'); | ||||
| @@ -87,10 +90,22 @@ class ActivityType extends AbstractType | ||||
|         $activityType = $options['activityType']; | ||||
|  | ||||
|         if (null !== $options['data']->getPerson()) { | ||||
|  | ||||
|             $reachableScopes = array_values( | ||||
|                 array_filter( | ||||
|                     $this->authorizationHelper->getReachableScopes( | ||||
|                         $this->security->getUser(), | ||||
|                         ActivityVoter::CREATE === (string) $options['role'] ? ActivityVoter::CREATE_PERSON : (string) $options['role'], | ||||
|                         $options['center'] | ||||
|                     ), | ||||
|                     static fn (Scope $s) => $s->isActive() | ||||
|                 ) | ||||
|             ); | ||||
|  | ||||
|             $builder->add('scope', ScopePickerType::class, [ | ||||
|                 'center' => $options['center'], | ||||
|                 'role' => ActivityVoter::CREATE === (string) $options['role'] ? ActivityVoter::CREATE_PERSON : (string) $options['role'], | ||||
|                 'reachable_scopes' => $reachableScopes, | ||||
|                 'required' => true, | ||||
|                 'label' => count($reachableScopes) > 1 ? 'Scope' : false, | ||||
|             ]); | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -136,8 +136,14 @@ export default { | ||||
|             issueIsLoading: false, | ||||
|             actionIsLoading: false, | ||||
|             actionAreLoaded: false, | ||||
|             socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`, | ||||
|             socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`, | ||||
|             socialIssuesClassList: { | ||||
|                 "col-form-label": true, | ||||
|                 required: false, | ||||
|             }, | ||||
|             socialActionsClassList: { | ||||
|                 "col-form-label": true, | ||||
|                 required: false, | ||||
|             }, | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -158,6 +164,21 @@ export default { | ||||
|         }, | ||||
|     }, | ||||
|     mounted() { | ||||
|         /* Load classNames after element is present */ | ||||
|         const socialActionsEl = document.querySelector( | ||||
|             "input#chill_activitybundle_activity_socialActions", | ||||
|         ); | ||||
|         if (socialActionsEl && socialActionsEl.hasAttribute("required")) { | ||||
|             this.socialActionsClassList.required = true; | ||||
|         } | ||||
|  | ||||
|         const socialIssuesEl = document.querySelector( | ||||
|             "input#chill_activitybundle_activity_socialIssues", | ||||
|         ); | ||||
|         if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) { | ||||
|             this.socialIssuesClassList.required = true; | ||||
|         } | ||||
|  | ||||
|         /* Load other issues in multiselect */ | ||||
|         this.issueIsLoading = true; | ||||
|         this.actionAreLoaded = false; | ||||
|   | ||||
| @@ -59,7 +59,7 @@ final readonly class StoredObjectVersionApiController | ||||
|  | ||||
|         return new JsonResponse( | ||||
|             $this->serializer->serialize( | ||||
|                 new Collection($items, $paginator), | ||||
|                 new Collection(array_values($items->toArray()), $paginator), | ||||
|                 'json', | ||||
|                 [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]] | ||||
|             ), | ||||
|   | ||||
| @@ -23,10 +23,14 @@ use Random\RandomException; | ||||
|  * Store each version of StoredObject's. | ||||
|  * | ||||
|  * A version should not be created manually: use the method @see{StoredObject::registerVersion} instead. | ||||
|  * | ||||
|  * Each filename must be unique within the same StoredObject. We add a condition on id to apply this condition only for | ||||
|  * newly created versions when this new index is applied. | ||||
|  */ | ||||
| #[ORM\Entity] | ||||
| #[ORM\Table('chill_doc.stored_object_version')] | ||||
| #[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])] | ||||
| #[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_filename', columns: ['filename'], options: ['where' => '(id > 0)'])] | ||||
| class StoredObjectVersion implements TrackCreationInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|   | ||||
| @@ -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\DocStoreBundle\Exception; | ||||
|  | ||||
| class ConversionWithSameMimeTypeException extends \RuntimeException | ||||
| { | ||||
|     public function __construct(string $mimeType, ?\Throwable $previous = null) | ||||
|     { | ||||
|         parent::__construct("Conversion to same MIME type '{$mimeType}' is not allowed: already at the same MIME type", 0, $previous); | ||||
|     } | ||||
| } | ||||
| @@ -14,9 +14,11 @@ namespace Chill\DocStoreBundle\Form; | ||||
| use Chill\DocStoreBundle\Entity\Document; | ||||
| use Chill\DocStoreBundle\Entity\DocumentCategory; | ||||
| use Chill\DocStoreBundle\Entity\PersonDocument; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Form\Type\ChillDateType; | ||||
| use Chill\MainBundle\Form\Type\ChillTextareaType; | ||||
| use Chill\MainBundle\Form\Type\ScopePickerType; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; | ||||
| use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; | ||||
| use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| @@ -27,10 +29,11 @@ use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\TextType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| class PersonDocumentType extends AbstractType | ||||
| { | ||||
|     public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly ScopeResolverDispatcher $scopeResolverDispatcher, private readonly ParameterBagInterface $parameterBag, private readonly CenterResolverDispatcher $centerResolverDispatcher) {} | ||||
|     public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly Security $security, private readonly AuthorizationHelperInterface $authorizationHelper, private readonly ScopeResolverDispatcher $scopeResolverDispatcher, private readonly ParameterBagInterface $parameterBag, private readonly CenterResolverDispatcher $centerResolverDispatcher) {} | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
|     { | ||||
| @@ -56,9 +59,19 @@ class PersonDocumentType extends AbstractType | ||||
|             ]); | ||||
|  | ||||
|         if ($isScopeConcerned && $this->parameterBag->get('chill_main')['acl']['form_show_scopes']) { | ||||
|             $reachableScopes = array_values( | ||||
|                 array_filter( | ||||
|                     $this->authorizationHelper->getReachableScopes( | ||||
|                         $this->security->getUser(), | ||||
|                         $options['role'], | ||||
|                         $this->centerResolverDispatcher->resolveCenter($document) | ||||
|                     ), | ||||
|                     static fn (Scope $s) => $s->isActive() | ||||
|                 ) | ||||
|             ); | ||||
|             $builder->add('scope', ScopePickerType::class, [ | ||||
|                 'center' => $this->centerResolverDispatcher->resolveCenter($document), | ||||
|                 'role' => $options['role'], | ||||
|                 'reachable_scopes' => $reachableScopes, | ||||
|                 'label' => count($reachableScopes) > 1 ? 'Scope' : false, | ||||
|             ]); | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -25,7 +25,7 @@ export interface GenericDoc { | ||||
|     type: "doc_store_generic_doc"; | ||||
|     uniqueKey: string; | ||||
|     key: string; | ||||
|     identifiers: object; | ||||
|     identifiers: { id: number }; | ||||
|     context: "person" | "accompanying-period"; | ||||
|     doc_date: DateTime; | ||||
|     metadata: GenericDocMetadata; | ||||
| @@ -36,6 +36,18 @@ export interface GenericDocForAccompanyingPeriod extends GenericDoc { | ||||
|     context: "accompanying-period"; | ||||
| } | ||||
|  | ||||
| export function isGenericDocForAccompanyingPeriod( | ||||
|     doc: GenericDoc, | ||||
| ): doc is GenericDocForAccompanyingPeriod { | ||||
|     return doc.context === "accompanying-period"; | ||||
| } | ||||
|  | ||||
| export function isGenericDocWithStoredObject( | ||||
|     doc: GenericDoc, | ||||
| ): doc is GenericDoc & { storedObject: StoredObject } { | ||||
|     return doc.storedObject !== null; | ||||
| } | ||||
|  | ||||
| interface BaseMetadataWithHtml extends BaseMetadata { | ||||
|     html: string; | ||||
| } | ||||
| @@ -44,28 +56,33 @@ export interface GenericDocForAccompanyingCourseDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "accompanying_course_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|  | ||||
| export interface GenericDocForAccompanyingCourseActivityDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "accompanying_course_activity_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|  | ||||
| export interface GenericDocForAccompanyingCourseCalendarDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "accompanying_course_calendar_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|  | ||||
| export interface GenericDocForAccompanyingCoursePersonDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "person_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|  | ||||
| export interface GenericDocForAccompanyingCourseWorkEvaluationDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "accompanying_period_work_evaluation_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|   | ||||
| @@ -3,9 +3,9 @@ import { | ||||
|     StoredObject, | ||||
|     StoredObjectPointInTime, | ||||
|     StoredObjectVersionWithPointInTime, | ||||
| } from "./../../../types"; | ||||
| } from "ChillDocStoreAssets/types"; | ||||
| import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; | ||||
| import { ISOToDatetime } from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date"; | ||||
| import { ISOToDatetime } from "ChillMainAssets/chill/js/date"; | ||||
| import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue"; | ||||
| import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue"; | ||||
| import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue"; | ||||
|   | ||||
| @@ -46,6 +46,16 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface | ||||
|  | ||||
|     public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         // we first try to get the permission from the workflow, as attachement (this is the less intensive query) | ||||
|         $workflowPermissionAsAttachment = match ($attribute) { | ||||
|             StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject), | ||||
|             StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject), | ||||
|         }; | ||||
|  | ||||
|         if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Retrieve the related entity | ||||
|         $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); | ||||
|  | ||||
| @@ -66,7 +76,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface | ||||
|         return match ($workflowPermission) { | ||||
|             WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true, | ||||
|             WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false, | ||||
|             WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission, | ||||
|             WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission, | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -14,6 +14,12 @@ namespace Chill\DocStoreBundle\Security\Authorization; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
|  | ||||
| /** | ||||
|  * Interface for voting on stored object permissions. | ||||
|  * | ||||
|  * Each time a stored object is attached to a document, the voter is responsible for determining | ||||
|  * whether the user has the necessary permissions to access or modify the stored object. | ||||
|  */ | ||||
| interface StoredObjectVoterInterface | ||||
| { | ||||
|     public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool; | ||||
|   | ||||
| @@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectVersion; | ||||
| use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException; | ||||
| use Chill\DocStoreBundle\Exception\StoredObjectManagerException; | ||||
| use Chill\WopiBundle\Service\WopiConverter; | ||||
| use Symfony\Component\Mime\MimeTypesInterface; | ||||
| @@ -41,9 +42,10 @@ class StoredObjectToPdfConverter | ||||
|      * | ||||
|      * @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion, 2?: string} contains the point in time before conversion and the new version of the stored object. The converted content is included in the response if $includeConvertedContent is true | ||||
|      * | ||||
|      * @throws \UnexpectedValueException    if the preferred mime type for the conversion is not found | ||||
|      * @throws \RuntimeException            if the conversion or storage of the new version fails | ||||
|      * @throws \UnexpectedValueException           if the preferred mime type for the conversion is not found | ||||
|      * @throws \RuntimeException                   if the conversion or storage of the new version fails | ||||
|      * @throws StoredObjectManagerException | ||||
|      * @throws ConversionWithSameMimeTypeException if the document has already the same mime type79* | ||||
|      */ | ||||
|     public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array | ||||
|     { | ||||
| @@ -56,7 +58,7 @@ class StoredObjectToPdfConverter | ||||
|         $currentVersion = $storedObject->getCurrentVersion(); | ||||
|  | ||||
|         if ($currentVersion->getType() === $newMimeType) { | ||||
|             throw new \UnexpectedValueException('Already at the same mime type'); | ||||
|             throw new ConversionWithSameMimeTypeException($newMimeType); | ||||
|         } | ||||
|  | ||||
|         $content = $this->storedObjectManager->read($currentVersion); | ||||
|   | ||||
| @@ -40,6 +40,10 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase | ||||
|             $storedObject->registerVersion(); | ||||
|         } | ||||
|  | ||||
|         // remove one version in the history | ||||
|         $v5 = $storedObject->getVersions()->get(5); | ||||
|         $storedObject->removeVersion($v5); | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject) | ||||
|             ->willReturn(true) | ||||
| @@ -53,6 +57,7 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase | ||||
|         self::assertEquals($response->getStatusCode(), 200); | ||||
|         self::assertIsArray($body); | ||||
|         self::assertArrayHasKey('results', $body); | ||||
|         self::assertIsList($body['results']); | ||||
|         self::assertCount(10, $body['results']); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -86,9 +86,165 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider dataProviderVoteOnAttribute | ||||
|      * @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission | ||||
|      */ | ||||
|     public function testVoteOnAttribute( | ||||
|     public function testVoteOnAttributeWithStoredObjectPermission( | ||||
|         StoredObjectRoleEnum $attribute, | ||||
|         bool $expected, | ||||
|         bool $isGrantedRegularPermission, | ||||
|         string $isGrantedWorkflowPermission, | ||||
|         string $isGrantedStoredObjectAttachment, | ||||
|     ): void { | ||||
|         $storedObject = new StoredObject(); | ||||
|         $repository = new DummyRepository($related = new \stdClass()); | ||||
|         $token = new UsernamePasswordToken(new User(), 'dummy'); | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); | ||||
|  | ||||
|         $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); | ||||
|  | ||||
|         if (StoredObjectRoleEnum::SEE === $attribute) { | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject) | ||||
|                 ->shouldBeCalled() | ||||
|                 ->willReturn($isGrantedStoredObjectAttachment); | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) | ||||
|                 ->willReturn($isGrantedWorkflowPermission); | ||||
|         } elseif (StoredObjectRoleEnum::EDIT === $attribute) { | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject) | ||||
|                 ->shouldBeCalled() | ||||
|                 ->willReturn($isGrantedStoredObjectAttachment); | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related) | ||||
|                 ->willReturn($isGrantedWorkflowPermission); | ||||
|         } else { | ||||
|             throw new \LogicException('Invalid attribute for StoredObjectVoter'); | ||||
|         } | ||||
|  | ||||
|         $storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal()) extends AbstractStoredObjectVoter { | ||||
|             public function __construct(private $repository, $helper, $security) | ||||
|             { | ||||
|                 parent::__construct($security, $helper); | ||||
|             } | ||||
|  | ||||
|             protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|             { | ||||
|                 return $this->repository; | ||||
|             } | ||||
|  | ||||
|             protected function getClass(): string | ||||
|             { | ||||
|                 return \stdClass::class; | ||||
|             } | ||||
|  | ||||
|             protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|             { | ||||
|                 return 'SOME_ROLE'; | ||||
|             } | ||||
|  | ||||
|             protected function canBeAssociatedWithWorkflow(): bool | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         $actual = $storedObjectVoter->voteOnAttribute($attribute, $storedObject, $token); | ||||
|  | ||||
|         self::assertEquals($expected, $actual); | ||||
|     } | ||||
|  | ||||
|     public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable | ||||
|     { | ||||
|         foreach (['read' => StoredObjectRoleEnum::SEE, 'write' => StoredObjectRoleEnum::EDIT] as $action => $attribute) { | ||||
|             yield 'Not related to any workflow nor attachment ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 true, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (stored object) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (workflow) although grant ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (initially refused) (stored object) although grant ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Force grant inverse the regular permission (workflow) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 true, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Force grant inverse the regular permission (so) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 true, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|             ]; | ||||
|  | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission | ||||
|      */ | ||||
|     public function testVoteOnAttributeWithoutStoredObjectPermission( | ||||
|         StoredObjectRoleEnum $attribute, | ||||
|         bool $expected, | ||||
|         bool $canBeAssociatedWithWorkflow, | ||||
| @@ -105,6 +261,10 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|         $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); | ||||
|  | ||||
|         $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); | ||||
|  | ||||
|         $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN); | ||||
|         $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN); | ||||
|  | ||||
|         if (null !== $isGrantedWorkflowPermissionRead) { | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) | ||||
|                 ->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled(); | ||||
| @@ -123,7 +283,7 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|         self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message); | ||||
|     } | ||||
|  | ||||
|     public static function dataProviderVoteOnAttribute(): iterable | ||||
|     public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable | ||||
|     { | ||||
|         // not associated on a workflow | ||||
|         yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper']; | ||||
|   | ||||
| @@ -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\Migrations\DocStore; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20251013094414 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'DocStore: Enforce filename uniqueness on chill_doc.stored_object_version; clean duplicates and add partial unique index on filename (for new rows only).'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         // 1) Clean duplicates: for each (stored_object_id, filename, key, iv), keep only the last inserted row | ||||
|         //    and delete all others. Use ROW_NUMBER over id DESC to define the last one. | ||||
|         $this->addSql(<<<'SQL' | ||||
|             WITH ranked AS ( | ||||
|                 SELECT id, | ||||
|                        rank() OVER ( | ||||
|                            PARTITION BY stored_object_id, filename, "key"::jsonb, iv::jsonb | ||||
|                            ORDER BY id DESC | ||||
|                        ) AS rn | ||||
|                 FROM chill_doc.stored_object_version | ||||
|             ) | ||||
|             DELETE FROM chill_doc.stored_object_version sov | ||||
|             USING ranked r | ||||
|             WHERE sov.id = r.id | ||||
|               AND r.rn > 1 | ||||
|         SQL); | ||||
|  | ||||
|         // 2) Create a partial unique index on filename that applies only to subsequently inserted rows. | ||||
|         //    Per user's instruction, compute the cutoff using the stored_object_id sequence value. | ||||
|         $nextVal = (int) $this->connection->fetchOne("SELECT nextval('chill_doc.stored_object_version_id_seq')"); | ||||
|  | ||||
|         // Safety: if somehow sequence is not available, fallback to current max id from the table | ||||
|         if ($nextVal <= 0) { | ||||
|             $nextVal = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(id), 0) FROM chill_doc.stored_object_version'); | ||||
|         } | ||||
|  | ||||
|         $this->addSql(sprintf( | ||||
|             'CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_filename ON chill_doc.stored_object_version (filename) WHERE id > %d', | ||||
|             $nextVal | ||||
|         )); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         // Drop the partial unique index; data cleanup is irreversible. | ||||
|         $this->addSql('DROP INDEX IF EXISTS chill_doc_stored_object_version_unique_by_filename'); | ||||
|     } | ||||
| } | ||||
| @@ -334,7 +334,7 @@ class ChillImportUsersCommand extends Command | ||||
|  | ||||
|     protected function loadUsers() | ||||
|     { | ||||
|         $reader = Reader::createFromPath($this->tempInput->getArgument('csvfile')); | ||||
|         $reader = Reader::from($this->tempInput->getArgument('csvfile')); | ||||
|         $reader->setHeaderOffset(0); | ||||
|  | ||||
|         foreach ($reader->getRecords() as $line => $r) { | ||||
| @@ -362,7 +362,7 @@ class ChillImportUsersCommand extends Command | ||||
|  | ||||
|     protected function prepareGroupingCenters() | ||||
|     { | ||||
|         $reader = Reader::createFromPath($this->tempInput->getOption('grouping-centers')); | ||||
|         $reader = Reader::from($this->tempInput->getOption('grouping-centers')); | ||||
|         $reader->setHeaderOffset(0); | ||||
|  | ||||
|         foreach ($reader->getRecords() as $r) { | ||||
| @@ -378,7 +378,7 @@ class ChillImportUsersCommand extends Command | ||||
|  | ||||
|     protected function prepareWriter() | ||||
|     { | ||||
|         $this->output = $output = Writer::createFromPath($this->tempInput | ||||
|         $this->output = $output = Writer::from($this->tempInput | ||||
|             ->getOption('csv-dump'), 'a+'); | ||||
|  | ||||
|         $output->insertOne([ | ||||
|   | ||||
| @@ -119,7 +119,7 @@ class ChillUserSendRenewPasswordCodeCommand extends Command | ||||
|     protected function getReader() | ||||
|     { | ||||
|         try { | ||||
|             $reader = Reader::createFromPath($this->input->getArgument('csvfile')); | ||||
|             $reader = Reader::from($this->input->getArgument('csvfile')); | ||||
|         } catch (\Exception $e) { | ||||
|             $this->logger->error('The csv file could not be read', [ | ||||
|                 'path' => $this->input->getArgument('csvfile'), | ||||
|   | ||||
| @@ -43,7 +43,7 @@ final readonly class UserExportController | ||||
|  | ||||
|         $users = $this->userRepository->findAllAsArray($request->getLocale()); | ||||
|  | ||||
|         $csv = Writer::createFromPath('php://temp', 'r+'); | ||||
|         $csv = Writer::from('php://temp', 'r+'); | ||||
|         $csv->insertOne( | ||||
|             array_map( | ||||
|                 fn (string $e) => $this->translator->trans('admin.users.export.'.$e), | ||||
| @@ -104,7 +104,7 @@ final readonly class UserExportController | ||||
|  | ||||
|         $userPermissions = $this->userRepository->findAllUserACLAsArray(); | ||||
|  | ||||
|         $csv = Writer::createFromPath('php://temp', 'r+'); | ||||
|         $csv = Writer::from('php://temp', 'r+'); | ||||
|         $csv->insertOne( | ||||
|             array_map( | ||||
|                 fn (string $e) => $this->translator->trans('admin.users.export.'.$e), | ||||
|   | ||||
| @@ -11,6 +11,7 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\CRUD\Controller\ApiController; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| @@ -27,7 +28,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\SerializerInterface; | ||||
|  | ||||
| class WorkflowApiController | ||||
| class WorkflowApiController extends ApiController | ||||
| { | ||||
|     public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {} | ||||
|  | ||||
|   | ||||
| @@ -264,11 +264,12 @@ class WorkflowController extends AbstractController | ||||
|     { | ||||
|         $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); | ||||
|  | ||||
|         $total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser()); | ||||
|         $total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false); | ||||
|         $paginator = $this->paginatorFactory->create($total); | ||||
|  | ||||
|         $workflows = $this->entityWorkflowRepository->findBySubscriber( | ||||
|             $this->security->getUser(), | ||||
|             false, | ||||
|             ['createdAt' => 'DESC'], | ||||
|             $paginator->getItemsPerPage(), | ||||
|             $paginator->getCurrentPageFirstItemNumber() | ||||
|   | ||||
| @@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|             $signature, | ||||
|             $request, | ||||
|             EntityWorkflowStepSignatureVoter::CANCEL, | ||||
|             function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); }, | ||||
|             fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature), | ||||
|             '@ChillMain/WorkflowSignature/cancel.html.twig', | ||||
|         ); | ||||
|     } | ||||
| @@ -56,11 +56,18 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|             $signature, | ||||
|             $request, | ||||
|             EntityWorkflowStepSignatureVoter::REJECT, | ||||
|             function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); }, | ||||
|             fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature), | ||||
|             '@ChillMain/WorkflowSignature/reject.html.twig', | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param callable(EntityWorkflowStepSignature): string $markSignature | ||||
|      * | ||||
|      * @throws \Twig\Error\LoaderError | ||||
|      * @throws \Twig\Error\RuntimeError | ||||
|      * @throws \Twig\Error\SyntaxError | ||||
|      */ | ||||
|     private function markSignatureAction( | ||||
|         EntityWorkflowStepSignature $signature, | ||||
|         Request $request, | ||||
| @@ -79,12 +86,13 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|         $form->handleRequest($request); | ||||
|  | ||||
|         if ($form->isSubmitted() && $form->isValid()) { | ||||
|             $this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) { | ||||
|                 $markSignature($signature); | ||||
|             }); | ||||
|             $expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature)); | ||||
|  | ||||
|             return new RedirectResponse( | ||||
|                 $this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()]) | ||||
|                 $this->chillUrlGenerator->forwardReturnPath( | ||||
|                     'chill_main_workflow_wait', | ||||
|                     ['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep] | ||||
|                 ) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,41 @@ | ||||
| <?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\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Routing\ChillUrlGeneratorInterface; | ||||
| use Symfony\Component\HttpFoundation\RedirectResponse; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Twig\Environment; | ||||
|  | ||||
| final readonly class WorkflowWaitStepChangeController | ||||
| { | ||||
|     public function __construct( | ||||
|         private ChillUrlGeneratorInterface $chillUrlGenerator, | ||||
|         private Environment $twig, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route('/{_locale}/main/workflow/{id}/wait/{expectedStep}', name: 'chill_main_workflow_wait', methods: ['GET'])] | ||||
|     public function waitForSignatureChange(EntityWorkflow $entityWorkflow, string $expectedStep): Response | ||||
|     { | ||||
|         if ($entityWorkflow->getStep() === $expectedStep) { | ||||
|             return new RedirectResponse( | ||||
|                 $this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return new Response( | ||||
|             $this->twig->render('@ChillMain/Workflow/waiting.html.twig', ['workflow' => $entityWorkflow, 'expectedStep' => $expectedStep]) | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -30,6 +30,7 @@ use Chill\MainBundle\Controller\UserGroupAdminController; | ||||
| use Chill\MainBundle\Controller\UserGroupApiController; | ||||
| use Chill\MainBundle\Controller\UserJobApiController; | ||||
| use Chill\MainBundle\Controller\UserJobController; | ||||
| use Chill\MainBundle\Controller\WorkflowApiController; | ||||
| use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; | ||||
| use Chill\MainBundle\Doctrine\DQL\Age; | ||||
| use Chill\MainBundle\Doctrine\DQL\Extract; | ||||
| @@ -66,6 +67,7 @@ use Chill\MainBundle\Entity\Regroupment; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\UserGroup; | ||||
| use Chill\MainBundle\Entity\UserJob; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Form\CenterType; | ||||
| use Chill\MainBundle\Form\CivilityType; | ||||
| use Chill\MainBundle\Form\CountryType; | ||||
| @@ -79,6 +81,7 @@ use Chill\MainBundle\Form\UserGroupType; | ||||
| use Chill\MainBundle\Form\UserJobType; | ||||
| use Chill\MainBundle\Form\UserType; | ||||
| use Chill\MainBundle\Security\Authorization\ChillExportVoter; | ||||
| use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter; | ||||
| use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; | ||||
| use Ramsey\Uuid\Doctrine\UuidType; | ||||
| use Symfony\Component\Config\FileLocator; | ||||
| @@ -202,6 +205,11 @@ class ChillMainExtension extends Extension implements | ||||
|                 [] | ||||
|         ); | ||||
|  | ||||
|         $container->setParameter( | ||||
|             'chill_main.top_banner', | ||||
|             $config['top_banner'] ?? [] | ||||
|         ); | ||||
|  | ||||
|         $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); | ||||
|         $loader->load('services.yaml'); | ||||
|         $loader->load('services/doctrine.yaml'); | ||||
| @@ -247,6 +255,7 @@ class ChillMainExtension extends Extension implements | ||||
|                     'name' => $config['installation_name'], ], | ||||
|                 'available_languages' => $config['available_languages'], | ||||
|                 'add_address' => $config['add_address'], | ||||
|                 'chill_main_config' => $config, | ||||
|             ], | ||||
|             'form_themes' => ['@ChillMain/Form/fields.html.twig'], | ||||
|         ]; | ||||
| @@ -940,6 +949,21 @@ class ChillMainExtension extends Extension implements | ||||
|                         ], | ||||
|                     ], | ||||
|                 ], | ||||
|                 [ | ||||
|                     'class' => EntityWorkflow::class, | ||||
|                     'name' => 'workflow', | ||||
|                     'base_path' => '/api/1.0/main/workflow', | ||||
|                     'base_role' => EntityWorkflowVoter::SEE, | ||||
|                     'controller' => WorkflowApiController::class, | ||||
|                     'actions' => [ | ||||
|                         '_entity' => [ | ||||
|                             'methods' => [ | ||||
|                                 Request::METHOD_GET => true, | ||||
|                                 Request::METHOD_HEAD => true, | ||||
|                             ], | ||||
|                         ], | ||||
|                     ], | ||||
|                 ], | ||||
|             ], | ||||
|         ]); | ||||
|     } | ||||
|   | ||||
| @@ -168,6 +168,20 @@ class Configuration implements ConfigurationInterface | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->arrayNode('top_banner') | ||||
|             ->canBeUnset() | ||||
|             ->children() | ||||
|             ->booleanNode('visible') | ||||
|             ->defaultFalse() | ||||
|             ->end() | ||||
|             ->arrayNode('text') | ||||
|             ->useAttributeAsKey('lang') | ||||
|             ->scalarPrototype()->end() | ||||
|             ->end() // end of text | ||||
|             ->scalarNode('color')->defaultNull()->end() | ||||
|             ->scalarNode('background_color')->defaultNull()->end() | ||||
|             ->end() // end of top_banner children | ||||
|             ->end() // end of top_banner | ||||
|             ->arrayNode('widgets') | ||||
|             ->canBeEnabled() | ||||
|             ->canBeUnset() | ||||
|   | ||||
| @@ -40,41 +40,39 @@ use Symfony\Component\Security\Core\Security; | ||||
| class ScopePickerType extends AbstractType | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly AuthorizationHelperInterface $authorizationHelper, | ||||
|         private readonly Security $security, | ||||
|         private readonly TranslatableStringHelperInterface $translatableStringHelper, | ||||
|     ) {} | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
|     { | ||||
|         $items = array_values( | ||||
|             array_filter( | ||||
|                 $this->authorizationHelper->getReachableScopes( | ||||
|                     $this->security->getUser(), | ||||
|                     $options['role'], | ||||
|                     $options['center'] | ||||
|                 ), | ||||
|                 static fn (Scope $s) => $s->isActive() | ||||
|             ) | ||||
|         ); | ||||
|         /*        $items = array_values( | ||||
|                     array_filter( | ||||
|                         $this->authorizationHelper->getReachableScopes( | ||||
|                             $this->security->getUser(), | ||||
|                             $options['role'], | ||||
|                             $options['center'] | ||||
|                         ), | ||||
|                         static fn (Scope $s) => $s->isActive() | ||||
|                     ) | ||||
|                 );*/ | ||||
|  | ||||
|         if (0 === \count($items)) { | ||||
|         if (0 === \count($options['reachable_scopes'])) { | ||||
|             throw new \RuntimeException('no scopes are reachable. This form should not be shown to user'); | ||||
|         } | ||||
|  | ||||
|         if (1 !== \count($items)) { | ||||
|         if (1 !== \count($options['reachable_scopes'])) { | ||||
|             $builder->add('scope', EntityType::class, [ | ||||
|                 'class' => Scope::class, | ||||
|                 'placeholder' => 'Choose the circle', | ||||
|                 'choice_label' => fn (Scope $c) => $this->translatableStringHelper->localize($c->getName()), | ||||
|                 'choices' => $items, | ||||
|                 'choices' => $options['reachable_scopes'], | ||||
|             ]); | ||||
|             $builder->setDataMapper(new ScopePickerDataMapper()); | ||||
|         } else { | ||||
|             $builder->add('scope', HiddenType::class, [ | ||||
|                 'data' => $items[0]->getId(), | ||||
|                 'data' => $options['reachable_scopes'][0]->getId(), | ||||
|             ]); | ||||
|             $builder->setDataMapper(new ScopePickerDataMapper($items[0])); | ||||
|             $builder->setDataMapper(new ScopePickerDataMapper($options['reachable_scopes'][0])); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -86,11 +84,13 @@ class ScopePickerType extends AbstractType | ||||
|     public function configureOptions(OptionsResolver $resolver) | ||||
|     { | ||||
|         $resolver | ||||
|           // create `center` option | ||||
|             ->setRequired('center') | ||||
|             ->setAllowedTypes('center', [Center::class, 'array', 'null']) | ||||
|           // create ``role` option | ||||
|             ->setRequired('role') | ||||
|             ->setAllowedTypes('role', ['string']); | ||||
|             ->setRequired('reachable_scopes') | ||||
|             ->setAllowedTypes('reachable_scopes', ['array']); | ||||
|         // create `center` option | ||||
|         //            ->setRequired('center') | ||||
|         //            ->setAllowedTypes('center', [Center::class, 'array', 'null']) | ||||
|         // create ``role` option | ||||
|         //            ->setRequired('role') | ||||
|         //            ->setAllowedTypes('role', ['string']); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -59,7 +59,7 @@ class UserPasswordType extends AbstractType | ||||
|                 'invalid_message' => 'The password fields must match', | ||||
|                 'constraints' => [ | ||||
|                     new Length([ | ||||
|                         'min' => 9, | ||||
|                         'min' => 14, | ||||
|                         'minMessage' => 'The password must be greater than {{ limit }} characters', | ||||
|                     ]), | ||||
|                     new NotBlank(), | ||||
|   | ||||
| @@ -53,11 +53,16 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface | ||||
|     public function run(array $lastExecutionData): ?array | ||||
|     { | ||||
|         $now = $this->clock->now(); | ||||
|  | ||||
|         if (isset($lastExecutionData['last_execution'])) { | ||||
|             $lastExecution = \DateTimeImmutable::createFromFormat( | ||||
|                 \DateTimeImmutable::ATOM, | ||||
|                 $lastExecutionData['last_execution'] | ||||
|             ); | ||||
|  | ||||
|             if (false === $lastExecution) { | ||||
|                 $lastExecution = $now->sub(new \DateInterval('P1D')); | ||||
|             } | ||||
|         } else { | ||||
|             $lastExecution = $now->sub(new \DateInterval('P1D')); | ||||
|         } | ||||
| @@ -96,7 +101,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface | ||||
|         ]); | ||||
|  | ||||
|         return [ | ||||
|             'last_execution' => $now->format('Y-m-d-H:i:s.u e'), | ||||
|             'last_execution' => $now->format(\DateTimeInterface::ATOM), | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -57,9 +57,15 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return (int) $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     public function countBySubscriber(User $user): int | ||||
|     /** | ||||
|      * @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore | ||||
|      * | ||||
|      * @throws \Doctrine\ORM\NoResultException | ||||
|      * @throws \Doctrine\ORM\NonUniqueResultException | ||||
|      */ | ||||
|     public function countBySubscriber(User $user, ?bool $isFinal = null): int | ||||
|     { | ||||
|         $qb = $this->buildQueryBySubscriber($user)->select('count(ew)'); | ||||
|         $qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)'); | ||||
|  | ||||
|         return (int) $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
| @@ -182,9 +188,14 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     /** | ||||
|      * @param bool|null  $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore | ||||
|      * @param mixed|null $limit | ||||
|      * @param mixed|null $offset | ||||
|      */ | ||||
|     public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     { | ||||
|         $qb = $this->buildQueryBySubscriber($user)->select('ew'); | ||||
|         $qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew'); | ||||
|  | ||||
|         foreach ($orderBy as $key => $sort) { | ||||
|             $qb->addOrderBy('ew.'.$key, $sort); | ||||
| @@ -312,7 +323,7 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return $qb; | ||||
|     } | ||||
|  | ||||
|     private function buildQueryBySubscriber(User $user): QueryBuilder | ||||
|     private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('ew'); | ||||
|  | ||||
| @@ -325,6 +336,14 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|  | ||||
|         $qb->setParameter('user', $user); | ||||
|  | ||||
|         if (null !== $isFinal) { | ||||
|             if ($isFinal) { | ||||
|                 $qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class)); | ||||
|             } else { | ||||
|                 $qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,13 @@ | ||||
| /** | ||||
|  * Extracts the "returnPath" parameter from the current URL's query string and returns it. | ||||
|  * If the parameter is not present, returns the provided fallback path. | ||||
|  * | ||||
|  * @param {string} fallbackPath - The fallback path to use if "returnPath" is not found in the query string. | ||||
|  * @return {string} The "returnPath" from the query string, or the fallback path if "returnPath" is not present. | ||||
|  */ | ||||
| export function returnPathOr(fallbackPath: string): string { | ||||
|     const urlParams = new URLSearchParams(window.location.search); | ||||
|     const returnPath = urlParams.get("returnPath"); | ||||
|  | ||||
|     return returnPath ?? fallbackPath; | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| import { EntityWorkflow } from "ChillMainAssets/types"; | ||||
| import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; | ||||
|  | ||||
| export const fetchWorkflow = async ( | ||||
|     workflowId: number, | ||||
| ): Promise<EntityWorkflow> => { | ||||
|     try { | ||||
|         return await makeFetch<null, EntityWorkflow>( | ||||
|             "GET", | ||||
|             `/api/1.0/main/workflow/${workflowId}.json`, | ||||
|         ); | ||||
|     } catch (error) { | ||||
|         console.error(`Failed to fetch workflow ${workflowId}:`, error); | ||||
|         throw error; | ||||
|     } | ||||
| }; | ||||
| @@ -1,5 +1,9 @@ | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { | ||||
|     GenericDoc, | ||||
|     isGenericDocWithStoredObject, | ||||
| } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; | ||||
| import { Person } from "../../../ChillPersonBundle/Resources/public/types"; | ||||
|  | ||||
| export interface DateTime { | ||||
|     datetime: string; | ||||
| @@ -202,6 +206,77 @@ export interface WorkflowAttachment { | ||||
|     genericDoc: null | GenericDoc; | ||||
| } | ||||
|  | ||||
| export type AttachmentWithDocAndStored = WorkflowAttachment & { | ||||
|     genericDoc: GenericDoc & { storedObject: StoredObject }; | ||||
| }; | ||||
|  | ||||
| export function isAttachmentWithDocAndStored( | ||||
|     a: WorkflowAttachment, | ||||
| ): a is AttachmentWithDocAndStored { | ||||
|     return ( | ||||
|         isWorkflowAttachmentWithGenericDoc(a) && | ||||
|         isGenericDocWithStoredObject(a.genericDoc) | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export function isWorkflowAttachmentWithGenericDoc( | ||||
|     attachment: WorkflowAttachment, | ||||
| ): attachment is WorkflowAttachment & { genericDoc: GenericDoc } { | ||||
|     return attachment.genericDoc !== null; | ||||
| } | ||||
|  | ||||
| export interface Workflow { | ||||
|     name: string; | ||||
|     text: string; | ||||
| } | ||||
|  | ||||
| export interface EntityWorkflowStep { | ||||
|     type: "entity_workflow_step"; | ||||
|     id: number; | ||||
|     comment: string; | ||||
|     currentStep: StepDefinition; | ||||
|     isFinal: boolean; | ||||
|     isFreezed: boolean; | ||||
|     isFinalized: boolean; | ||||
|     transitionPrevious: Transition | null; | ||||
|     transitionAfter: Transition | null; | ||||
|     previousId: number | null; | ||||
|     nextId: number | null; | ||||
|     transitionPreviousBy: User | null; | ||||
|     transitionPreviousAt: DateTime | null; | ||||
| } | ||||
|  | ||||
| export interface Transition { | ||||
|     name: string; | ||||
|     text: string; | ||||
|     isForward: boolean; | ||||
| } | ||||
|  | ||||
| export interface StepDefinition { | ||||
|     name: string; | ||||
|     text: string; | ||||
| } | ||||
|  | ||||
| export interface EntityWorkflow { | ||||
|     type: "entity_workflow"; | ||||
|     id: number; | ||||
|     relatedEntityClass: string; | ||||
|     relatedEntityId: number; | ||||
|     workflow: Workflow; | ||||
|     currentStep: EntityWorkflowStep; | ||||
|     steps: EntityWorkflowStep[]; | ||||
|     datas: WorkflowData; | ||||
|     title: string; | ||||
|     isOnHoldAtCurrentStep: boolean; | ||||
|     _permissions: { | ||||
|         CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT: boolean; | ||||
|     }; | ||||
| } | ||||
|  | ||||
| export interface WorkflowData { | ||||
|     persons: Person[]; | ||||
| } | ||||
|  | ||||
| export interface ExportGeneration { | ||||
|     id: string; | ||||
|     type: "export_generation"; | ||||
| @@ -215,3 +290,8 @@ export interface ExportGeneration { | ||||
| export interface PrivateCommentEmbeddable { | ||||
|     comments: Record<number, string>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Possible states for the WaitingScreen Component. | ||||
|  */ | ||||
| export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready"; | ||||
|   | ||||
| @@ -10,7 +10,8 @@ import { computed, onMounted, ref } from "vue"; | ||||
| import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; | ||||
| import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export"; | ||||
| import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; | ||||
| import { ExportGeneration } from "ChillMainAssets/types"; | ||||
| import WaitingScreen from "../_components/WaitingScreen.vue"; | ||||
| import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface AppProps { | ||||
|     exportGenerationId: string; | ||||
| @@ -34,13 +35,16 @@ const storedObject = computed<null | StoredObject>(() => { | ||||
| }); | ||||
|  | ||||
| const isPending = computed<boolean>(() => status.value === "pending"); | ||||
| const isFetching = computed<boolean>( | ||||
|     () => tryiesForReady.value < maxTryiesForReady, | ||||
| ); | ||||
| const isReady = computed<boolean>(() => status.value === "ready"); | ||||
| const isFailure = computed<boolean>(() => status.value === "failure"); | ||||
| const filename = computed<string>(() => `${props.title}-${props.createdDate}`); | ||||
|  | ||||
| const state = computed<WaitingScreenState>((): WaitingScreenState => { | ||||
|     if (status.value === "empty") { | ||||
|         return "pending"; | ||||
|     } | ||||
|  | ||||
|     return status.value; | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * counter for the number of times that we check for a new status | ||||
|  */ | ||||
| @@ -85,57 +89,36 @@ onMounted(() => { | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div id="waiting-screen"> | ||||
|         <div | ||||
|             v-if="isPending && isFetching" | ||||
|             class="alert alert-danger text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} | ||||
|                 </p> | ||||
|             </div> | ||||
|     <WaitingScreen :state="state"> | ||||
|         <template v-slot:pending> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} | ||||
|             </p> | ||||
|         </template> | ||||
|  | ||||
|             <div> | ||||
|                 <i class="fa fa-cog fa-spin fa-3x fa-fw"></i> | ||||
|                 <span class="sr-only">Loading...</span> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div v-if="isPending && !isFetching" class="alert alert-info"> | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }} | ||||
|                 </p> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div v-if="isFailure" class="alert alert-danger text-center"> | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }} | ||||
|                 </p> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div v-if="isReady" class="alert alert-success text-center"> | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_EXPORT_READY) }} | ||||
|                 </p> | ||||
|         <template v-slot:stopped> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }} | ||||
|             </p> | ||||
|         </template> | ||||
|  | ||||
|                 <p v-if="storedObject !== null"> | ||||
|                     <document-action-buttons-group | ||||
|                         :stored-object="storedObject" | ||||
|                         :filename="filename" | ||||
|                     ></document-action-buttons-group> | ||||
|                 </p> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|         <template v-slot:failure> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }} | ||||
|             </p> | ||||
|         </template> | ||||
|  | ||||
|         <template v-slot:ready> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_EXPORT_READY) }} | ||||
|             </p> | ||||
|  | ||||
|             <p v-if="storedObject !== null"> | ||||
|                 <document-action-buttons-group | ||||
|                     :stored-object="storedObject" | ||||
|                     :filename="filename" | ||||
|                 ></document-action-buttons-group> | ||||
|             </p> | ||||
|         </template> | ||||
|     </WaitingScreen> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| #waiting-screen { | ||||
|     > .alert { | ||||
|         min-height: 350px; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -0,0 +1,75 @@ | ||||
| <script setup lang="ts"> | ||||
| import { useIntervalFn } from "@vueuse/core"; | ||||
| import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api"; | ||||
| import { returnPathOr } from "ChillMainAssets/lib/return_path/returnPathHelper"; | ||||
| import { ref } from "vue"; | ||||
| import WaitingScreen from "ChillMainAssets/vuejs/_components/WaitingScreen.vue"; | ||||
| import { WaitingScreenState } from "ChillMainAssets/types"; | ||||
| import { | ||||
|     trans, | ||||
|     WORKFLOW_WAIT_TITLE, | ||||
|     WORKFLOW_WAIT_ERROR_WHILE_WAITING, | ||||
|     WORKFLOW_WAIT_SUCCESS, | ||||
| } from "translator"; | ||||
|  | ||||
| interface WaitPostProcessWorkflowComponentProps { | ||||
|     workflowId: number; | ||||
|     expectedStep: string; | ||||
| } | ||||
|  | ||||
| const props = defineProps<WaitPostProcessWorkflowComponentProps>(); | ||||
| const counter = ref<number>(0); | ||||
| const MAX_TRYIES = 50; | ||||
|  | ||||
| const state = ref<WaitingScreenState>("pending"); | ||||
|  | ||||
| const { pause, resume } = useIntervalFn( | ||||
|     async () => { | ||||
|         try { | ||||
|             const workflow = await fetchWorkflow(props.workflowId); | ||||
|             counter.value++; | ||||
|             if (workflow.currentStep.currentStep.name === props.expectedStep) { | ||||
|                 window.location.assign( | ||||
|                     returnPathOr("/fr/main/workflow" + workflow.id + "/show"), | ||||
|                 ); | ||||
|                 resume(); | ||||
|                 state.value = "ready"; | ||||
|             } | ||||
|  | ||||
|             if (counter.value > MAX_TRYIES) { | ||||
|                 pause(); | ||||
|                 state.value = "failure"; | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error(error); | ||||
|             pause(); | ||||
|         } | ||||
|     }, | ||||
|     2000, | ||||
|     { immediate: true }, | ||||
| ); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div class="container"> | ||||
|         <WaitingScreen :state="state"> | ||||
|             <template v-slot:pending> | ||||
|                 <p> | ||||
|                     {{ trans(WORKFLOW_WAIT_TITLE) }} | ||||
|                 </p> | ||||
|             </template> | ||||
|             <template v-slot:failure> | ||||
|                 <p> | ||||
|                     {{ trans(WORKFLOW_WAIT_ERROR_WHILE_WAITING) }} | ||||
|                 </p> | ||||
|             </template> | ||||
|             <template v-slot:ready> | ||||
|                 <p> | ||||
|                     {{ trans(WORKFLOW_WAIT_SUCCESS) }} | ||||
|                 </p> | ||||
|             </template> | ||||
|         </WaitingScreen> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"></style> | ||||
| @@ -0,0 +1,51 @@ | ||||
| import { createApp } from "vue"; | ||||
| import App from "./App.vue"; | ||||
|  | ||||
| function mountApp(): void { | ||||
|     const el = document.querySelector<HTMLDivElement>(".screen-wait"); | ||||
|     if (!el) { | ||||
|         console.error( | ||||
|             "WaitPostProcessWorkflow: mount element .screen-wait not found", | ||||
|         ); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const workflowIdAttr = el.getAttribute("data-workflow-id"); | ||||
|     const expectedStep = el.getAttribute("data-expected-step") || ""; | ||||
|  | ||||
|     if (!workflowIdAttr) { | ||||
|         console.error( | ||||
|             "WaitPostProcessWorkflow: data-workflow-id attribute missing on mount element", | ||||
|         ); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     if (!expectedStep) { | ||||
|         console.error( | ||||
|             "WaitPostProcessWorkflow: data-expected-step attribute missing on mount element", | ||||
|         ); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const workflowId = Number(workflowIdAttr); | ||||
|     if (Number.isNaN(workflowId)) { | ||||
|         console.error( | ||||
|             "WaitPostProcessWorkflow: data-workflow-id is not a valid number:", | ||||
|             workflowIdAttr, | ||||
|         ); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const app = createApp(App, { | ||||
|         workflowId, | ||||
|         expectedStep, | ||||
|     }); | ||||
|  | ||||
|     app.mount(el); | ||||
| } | ||||
|  | ||||
| if (document.readyState === "loading") { | ||||
|     document.addEventListener("DOMContentLoaded", mountApp); | ||||
| } else { | ||||
|     mountApp(); | ||||
| } | ||||
| @@ -1,10 +1,12 @@ | ||||
| <script setup lang="ts"> | ||||
| import { computed, useTemplateRef } from "vue"; | ||||
| import type { WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import { computed, onMounted, ref, useTemplateRef } from "vue"; | ||||
| import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue"; | ||||
| import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue"; | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types"; | ||||
| import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api"; | ||||
| import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator"; | ||||
|  | ||||
| interface AppConfig { | ||||
|     workflowId: number; | ||||
| @@ -34,6 +36,13 @@ const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>( | ||||
|             ) as GenericDocForAccompanyingPeriod[], | ||||
| ); | ||||
|  | ||||
| const workflow = ref<EntityWorkflow | null>(null); | ||||
|  | ||||
| onMounted(async () => { | ||||
|     workflow.value = await fetchWorkflow(Number(props.workflowId)); | ||||
|     console.log("workflow", workflow.value); | ||||
| }); | ||||
|  | ||||
| const openModal = function () { | ||||
|     pickDocModal.value?.openModal(); | ||||
| }; | ||||
| @@ -49,23 +58,33 @@ const onPickGenericDoc = ({ | ||||
| const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => { | ||||
|     emit("removeAttachment", payload); | ||||
| }; | ||||
|  | ||||
| const canEditAttachement = computed<boolean>(() => { | ||||
|     if (null === workflow.value) { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <pick-generic-doc-modal | ||||
|         :workflow="workflow" | ||||
|         :accompanying-period-id="props.accompanyingPeriodId" | ||||
|         :to-remove="attachedGenericDoc" | ||||
|         ref="pickDocModal" | ||||
|         @pickGenericDoc="onPickGenericDoc" | ||||
|     ></pick-generic-doc-modal> | ||||
|     <attachment-list | ||||
|         :workflow="workflow" | ||||
|         :attachments="props.attachments" | ||||
|         @removeAttachment="onRemoveAttachment" | ||||
|     ></attachment-list> | ||||
|     <ul class="record_actions"> | ||||
|     <ul v-if="canEditAttachement" class="record_actions"> | ||||
|         <li> | ||||
|             <button type="button" class="btn btn-create" @click="openModal"> | ||||
|                 Ajouter une pièce jointe | ||||
|                 {{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }} | ||||
|             </button> | ||||
|         </li> | ||||
|     </ul> | ||||
|   | ||||
| @@ -1,10 +1,18 @@ | ||||
| <script setup lang="ts"> | ||||
| import { WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import { | ||||
|     AttachmentWithDocAndStored, | ||||
|     EntityWorkflow, | ||||
|     isAttachmentWithDocAndStored, | ||||
|     WorkflowAttachment, | ||||
| } from "ChillMainAssets/types"; | ||||
| import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue"; | ||||
| import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; | ||||
| import { computed } from "vue"; | ||||
| import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator"; | ||||
|  | ||||
| interface AttachmentListProps { | ||||
|     attachments: WorkflowAttachment[]; | ||||
|     workflow: EntityWorkflow | null; | ||||
| } | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| @@ -13,30 +21,43 @@ const emit = defineEmits<{ | ||||
| }>(); | ||||
|  | ||||
| const props = defineProps<AttachmentListProps>(); | ||||
|  | ||||
| const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() => | ||||
|     props.attachments.filter( | ||||
|         (a: WorkflowAttachment): a is AttachmentWithDocAndStored => | ||||
|             isAttachmentWithDocAndStored(a), | ||||
|     ), | ||||
| ); | ||||
|  | ||||
| const canRemove = computed<boolean>((): boolean => { | ||||
|     if (null === props.workflow) { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <p | ||||
|         v-if="props.attachments.length === 0" | ||||
|         v-if="notNullAttachments.length === 0" | ||||
|         class="chill-no-data-statement text-center" | ||||
|     > | ||||
|         Aucune pièce jointe | ||||
|         {{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }} | ||||
|     </p> | ||||
|     <!-- TODO translate --> | ||||
|     <div else class="flex-table"> | ||||
|         <div v-for="a in props.attachments" :key="a.id" class="item-bloc"> | ||||
|     <div v-else class="flex-table"> | ||||
|         <div v-for="a in notNullAttachments" :key="a.id" class="item-bloc"> | ||||
|             <generic-doc-item-box | ||||
|                 v-if="a.genericDoc !== null" | ||||
|                 :generic-doc="a.genericDoc" | ||||
|             ></generic-doc-item-box> | ||||
|             <div class="item-row separator"> | ||||
|                 <ul class="record_actions"> | ||||
|                     <li v-if="a.genericDoc?.storedObject !== null"> | ||||
|                     <li> | ||||
|                         <document-action-buttons-group | ||||
|                             :stored-object="a.genericDoc.storedObject" | ||||
|                         ></document-action-buttons-group> | ||||
|                     </li> | ||||
|                     <li> | ||||
|                     <li v-if="canRemove"> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="btn btn-delete" | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <script setup lang="ts"> | ||||
| import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; | ||||
|  | ||||
| interface GenericDocItemBoxProps { | ||||
|     genericDoc: GenericDocForAccompanyingPeriod; | ||||
|     genericDoc: GenericDoc; | ||||
| } | ||||
|  | ||||
| const props = defineProps<GenericDocItemBoxProps>(); | ||||
|   | ||||
| @@ -6,8 +6,10 @@ import { | ||||
| import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue"; | ||||
| import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api"; | ||||
| import { computed, onMounted, ref } from "vue"; | ||||
| import { EntityWorkflow } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface PickGenericDocProps { | ||||
|     workflow: EntityWorkflow | null; | ||||
|     accompanyingPeriodId: number; | ||||
|     pickedList: GenericDocForAccompanyingPeriod[]; | ||||
|     toRemove: GenericDocForAccompanyingPeriod[]; | ||||
| @@ -36,9 +38,21 @@ const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean => | ||||
|     ) !== -1; | ||||
|  | ||||
| onMounted(async () => { | ||||
|     genericDocs.value = await fetch_generic_docs_by_accompanying_period( | ||||
|     const fetchedGenericDocs = await fetch_generic_docs_by_accompanying_period( | ||||
|         props.accompanyingPeriodId, | ||||
|     ); | ||||
|     const documentClasses = [ | ||||
|         "Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument", | ||||
|         "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument", | ||||
|         "Chill\\DocStoreBundle\\Entity\\PersonDocument", | ||||
|     ]; | ||||
|  | ||||
|     genericDocs.value = fetchedGenericDocs.filter( | ||||
|         (doc) => | ||||
|             !documentClasses.includes( | ||||
|                 props.workflow?.relatedEntityClass || "", | ||||
|             ) || props.workflow?.relatedEntityId !== doc.identifiers.id, | ||||
|     ); | ||||
|     loaded.value = true; | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -3,8 +3,10 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import { computed, ref, useTemplateRef } from "vue"; | ||||
| import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue"; | ||||
| import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { EntityWorkflow } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface PickGenericDocModalProps { | ||||
|     workflow: EntityWorkflow | null; | ||||
|     accompanyingPeriodId: number; | ||||
|     toRemove: GenericDocForAccompanyingPeriod[]; | ||||
| } | ||||
| @@ -80,6 +82,7 @@ defineExpose({ openModal, closeModal }); | ||||
|         </template> | ||||
|         <template v-slot:body> | ||||
|             <pick-generic-doc | ||||
|                 :workflow="props.workflow" | ||||
|                 :accompanying-period-id="props.accompanyingPeriodId" | ||||
|                 :to-remove="props.toRemove" | ||||
|                 :picked-list="pickeds" | ||||
|   | ||||
| @@ -0,0 +1,62 @@ | ||||
| <script setup lang="ts"> | ||||
| import { WaitingScreenState } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface Props { | ||||
|     state: WaitingScreenState; | ||||
| } | ||||
|  | ||||
| const props = defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div id="waiting-screen"> | ||||
|         <div | ||||
|             v-if="props.state === 'pending' && !!$slots.pending" | ||||
|             class="alert alert-danger text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <slot name="pending"></slot> | ||||
|             </div> | ||||
|  | ||||
|             <div> | ||||
|                 <i class="fa fa-cog fa-spin fa-3x fa-fw"></i> | ||||
|                 <span class="sr-only">Loading...</span> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|             v-if="props.state === 'stopped' && !!$slots.stopped" | ||||
|             class="alert alert-info" | ||||
|         > | ||||
|             <div> | ||||
|                 <slot name="stopped"></slot> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|             v-if="props.state === 'failure' && !!$slots.failure" | ||||
|             class="alert alert-danger text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <slot name="failure"></slot> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|             v-if="props.state === 'ready' && !!$slots.ready" | ||||
|             class="alert alert-success text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <slot name="ready"></slot> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| #waiting-screen { | ||||
|     > .alert { | ||||
|         min-height: 350px; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
| @@ -0,0 +1,17 @@ | ||||
| {% if chill_main_config.top_banner is defined and chill_main_config.top_banner.text is defined %} | ||||
|     {% set banner_text = '' %} | ||||
|     {% set current_locale = app.request.locale %} | ||||
|  | ||||
|     {% if chill_main_config.top_banner.text[current_locale] is defined %} | ||||
|         {% set banner_text = chill_main_config.top_banner.text[current_locale] %} | ||||
|     {% else %} | ||||
|         {% set banner_text = chill_main_config.top_banner.text|first %} | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if banner_text %} | ||||
|         <div class="top-banner w-100 text-center py-2" | ||||
|              style="{% if chill_main_config.top_banner.color is defined %}color: {{ chill_main_config.top_banner.color }};{% endif %}{% if chill_main_config.top_banner.background_color is defined %}background-color: {{ chill_main_config.top_banner.background_color }};{% endif %}"> | ||||
|             {{ banner_text }} | ||||
|         </div> | ||||
|     {% endif %} | ||||
| {% endif %} | ||||
| @@ -21,8 +21,6 @@ | ||||
|     {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} | ||||
|     {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} | ||||
|  | ||||
|     {{ form_row(form.addressesEmails) }} | ||||
|  | ||||
|     {% include handler.template(notification) with handler.templateData(notification) %} | ||||
|  | ||||
|     <div class="mb-3 row"> | ||||
|   | ||||
| @@ -58,12 +58,14 @@ | ||||
|         {% endif %} | ||||
|     </section> | ||||
|  | ||||
|     {% if signatures|length > 0 %} | ||||
|     <section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section> | ||||
|     {% endif %} | ||||
|  | ||||
|     <section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section> | ||||
|  | ||||
|     <section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section> | ||||
|     {% if signatures|length > 0 %} | ||||
|         <section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section> | ||||
|     {% elseif entity_workflow.currentStep.sends|length > 0 %} | ||||
|     {% if entity_workflow.currentStep.sends|length > 0 %} | ||||
|         <section class="step my-4"> | ||||
|             <h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2> | ||||
|             {% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %} | ||||
|   | ||||
| @@ -0,0 +1,18 @@ | ||||
| {% extends '@ChillMain/layout.html.twig' %} | ||||
|  | ||||
| {% block title %}{{ 'workflow.signature.waiting_for'|trans }}{% endblock %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ encore_entry_link_tags('page_workflow_waiting_post_process') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js %} | ||||
|     {{ encore_entry_script_tags('page_workflow_waiting_post_process') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|     <h1>{{ block('title') }}</h1> | ||||
|  | ||||
|     <div class="screen-wait" data-workflow-id="{{ workflow.id|e('html_attr') }}" data-expected-step="{{ expectedStep|e('html_attr') }}"></div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -26,6 +26,10 @@ | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|     {% if chill_main_config.top_banner is defined and chill_main_config.top_banner.visible is true %} | ||||
|         {{ include('@ChillMain/Layout/_top_banner.html.twig') }} | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if responsive_debug is defined and responsive_debug == 1 %} | ||||
|         {{ include('@ChillMain/Layout/_debug.html.twig') }} | ||||
|     {% endif %} | ||||
|   | ||||
| @@ -0,0 +1,53 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
|  | ||||
| final class EntityWorkflowAttachmentVoter extends Voter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly Registry $registry, | ||||
|     ) {} | ||||
|     public const EDIT = 'CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT'; | ||||
|  | ||||
|     protected function supports(string $attribute, $subject): bool | ||||
|     { | ||||
|         return $subject instanceof EntityWorkflow && self::EDIT === $attribute; | ||||
|     } | ||||
|  | ||||
|     protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         if (!$subject instanceof EntityWorkflow) { | ||||
|             throw new \UnexpectedValueException('Subject must be an instance of EntityWorkflow'); | ||||
|         } | ||||
|  | ||||
|         if ($subject->isFinal()) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         $workflow = $this->registry->get($subject, $subject->getWorkflowName()); | ||||
|  | ||||
|         $marking = $workflow->getMarking($subject); | ||||
|         foreach ($marking->getPlaces() as $place => $int) { | ||||
|             $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); | ||||
|             if ($placeMetadata['isSentExternal'] ?? false) { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -12,18 +12,25 @@ declare(strict_types=1); | ||||
| namespace Chill\MainBundle\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Chill\MainBundle\Workflow\Helper\MetadataExtractor; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
|  | ||||
| class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface | ||||
| final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface | ||||
| { | ||||
|     use NormalizerAwareTrait; | ||||
|  | ||||
|     public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly MetadataExtractor $metadataExtractor, private readonly Registry $registry) {} | ||||
|     public function __construct( | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|         private readonly MetadataExtractor $metadataExtractor, | ||||
|         private readonly Registry $registry, | ||||
|         private readonly Security $security, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * @param EntityWorkflow $object | ||||
| @@ -46,6 +53,9 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn | ||||
|             'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context), | ||||
|             'title' => $handler->getEntityTitle($object), | ||||
|             'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(), | ||||
|             '_permissions' => [ | ||||
|                 EntityWorkflowAttachmentVoter::EDIT => $this->security->isGranted(EntityWorkflowAttachmentVoter::EDIT, $object), | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -64,7 +64,7 @@ class AddressReferenceBEFromBestAddress | ||||
|  | ||||
|         $uncompressedStream = gzopen($tmpname, 'r'); | ||||
|  | ||||
|         $csv = Reader::createFromStream($uncompressedStream); | ||||
|         $csv = Reader::from($uncompressedStream); | ||||
|         $csv->setDelimiter(','); | ||||
|         $csv->setHeaderOffset(0); | ||||
|  | ||||
|   | ||||
| @@ -287,7 +287,7 @@ final class AddressReferenceBaseImporter | ||||
|  | ||||
|             $filename = sprintf('%s-%s.csv', (new \DateTimeImmutable())->format('Ymd-His'), uniqid()); | ||||
|             $path = Path::normalize(sprintf('%s%s%s', sys_get_temp_dir(), DIRECTORY_SEPARATOR, $filename)); | ||||
|             $writer = Writer::createFromPath($path, 'w+'); | ||||
|             $writer = Writer::from($path, 'w+'); | ||||
|             // insert headers | ||||
|             $writer->insertOne([ | ||||
|                 'postalcode', | ||||
|   | ||||
| @@ -53,7 +53,7 @@ class AddressReferenceFromBAN | ||||
|         // re-open it to read it | ||||
|         $csvDecompressed = gzopen($path, 'r'); | ||||
|  | ||||
|         $csv = Reader::createFromStream($csvDecompressed); | ||||
|         $csv = Reader::from($csvDecompressed); | ||||
|         $csv->setDelimiter(';')->setHeaderOffset(0); | ||||
|         $stmt = new Statement(); | ||||
|         $stmt = $stmt->process($csv, [ | ||||
|   | ||||
| @@ -41,7 +41,7 @@ class AddressReferenceFromBano | ||||
|  | ||||
|         fseek($file, 0); | ||||
|  | ||||
|         $csv = Reader::createFromStream($file); | ||||
|         $csv = Reader::from($file); | ||||
|         $csv->setDelimiter(','); | ||||
|         $stmt = new Statement(); | ||||
|         $stmt = $stmt->process($csv, [ | ||||
|   | ||||
| @@ -39,7 +39,7 @@ class AddressReferenceLU | ||||
|  | ||||
|         fseek($file, 0); | ||||
|  | ||||
|         $csv = Reader::createFromStream($file); | ||||
|         $csv = Reader::from($file); | ||||
|         $csv->setDelimiter(';'); | ||||
|         $csv->setHeaderOffset(0); | ||||
|  | ||||
|   | ||||
| @@ -43,7 +43,7 @@ class PostalCodeBEFromBestAddress | ||||
|  | ||||
|         $uncompressedStream = gzopen($tmpname, 'r'); | ||||
|  | ||||
|         $csv = Reader::createFromStream($uncompressedStream); | ||||
|         $csv = Reader::from($uncompressedStream); | ||||
|         $csv->setDelimiter(','); | ||||
|         $csv->setHeaderOffset(0); | ||||
|  | ||||
|   | ||||
| @@ -47,7 +47,7 @@ class PostalCodeFRFromOpenData | ||||
|  | ||||
|         fseek($tmpfile, 0); | ||||
|  | ||||
|         $csv = Reader::createFromStream($tmpfile); | ||||
|         $csv = Reader::from($tmpfile); | ||||
|         $csv->setDelimiter(','); | ||||
|         $csv->setHeaderOffset(0); | ||||
|  | ||||
|   | ||||
| @@ -18,7 +18,7 @@ use Symfony\Component\Notifier\Event\SentMessageEvent; | ||||
| final readonly class SentMessageEventSubscriber implements EventSubscriberInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private LoggerInterface $logger, | ||||
|         private LoggerInterface $notifierLogger, // will be send to "notifierLogger" if it exists | ||||
|     ) {} | ||||
|  | ||||
|     public static function getSubscribedEvents() | ||||
| @@ -33,9 +33,9 @@ final readonly class SentMessageEventSubscriber implements EventSubscriberInterf | ||||
|         $message = $event->getMessage(); | ||||
|  | ||||
|         if (null === $message->getMessageId()) { | ||||
|             $this->logger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]); | ||||
|             $this->notifierLogger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]); | ||||
|         } else { | ||||
|             $this->logger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]); | ||||
|             $this->notifierLogger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -45,7 +45,7 @@ final class UserControllerTest extends WebTestCase | ||||
|         self::assertResponseIsSuccessful(); | ||||
|  | ||||
|         $username = 'Test_user'.uniqid(); | ||||
|         $password = 'Password1234!'; | ||||
|         $password = 'Password_1234!'; | ||||
|  | ||||
|         // Fill in the form and submit it | ||||
|  | ||||
| @@ -99,7 +99,7 @@ final class UserControllerTest extends WebTestCase | ||||
|     { | ||||
|         $client = $this->getClientAuthenticatedAsAdmin(); | ||||
|         $crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password"); | ||||
|         $newPassword = '1234Password!'; | ||||
|         $newPassword = '1234_Password!'; | ||||
|  | ||||
|         $form = $crawler->selectButton('Changer le mot de passe')->form([ | ||||
|             'chill_mainbundle_user_password[new_password][first]' => $newPassword, | ||||
|   | ||||
| @@ -0,0 +1,98 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\DependencyInjection; | ||||
|  | ||||
| use Chill\MainBundle\DependencyInjection\Configuration; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Config\Definition\Processor; | ||||
| use Symfony\Component\DependencyInjection\ContainerBuilder; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class ConfigurationTest extends TestCase | ||||
| { | ||||
|     public function testTopBannerConfiguration(): void | ||||
|     { | ||||
|         $containerBuilder = new ContainerBuilder(); | ||||
|         $configuration = new Configuration([], $containerBuilder); | ||||
|         $processor = new Processor(); | ||||
|  | ||||
|         // Test with top_banner configuration | ||||
|         $config = [ | ||||
|             'chill_main' => [ | ||||
|                 'top_banner' => [ | ||||
|                     'text' => [ | ||||
|                         'fr' => 'Vous travaillez actuellement avec la version de pré-production de Chill.', | ||||
|                         'nl' => 'Je werkte momenteel in de pré-productie versie van Chill.', | ||||
|                     ], | ||||
|                     'color' => 'white', | ||||
|                     'background-color' => 'red', | ||||
|                 ], | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         $processedConfig = $processor->processConfiguration($configuration, $config); | ||||
|  | ||||
|         self::assertArrayHasKey('top_banner', $processedConfig); | ||||
|         self::assertArrayHasKey('text', $processedConfig['top_banner']); | ||||
|         self::assertArrayHasKey('fr', $processedConfig['top_banner']['text']); | ||||
|         self::assertArrayHasKey('nl', $processedConfig['top_banner']['text']); | ||||
|         self::assertSame('white', $processedConfig['top_banner']['color']); | ||||
|         self::assertSame('red', $processedConfig['top_banner']['background_color']); | ||||
|     } | ||||
|  | ||||
|     public function testTopBannerConfigurationOptional(): void | ||||
|     { | ||||
|         $containerBuilder = new ContainerBuilder(); | ||||
|         $configuration = new Configuration([], $containerBuilder); | ||||
|         $processor = new Processor(); | ||||
|  | ||||
|         // Test without top_banner configuration | ||||
|         $config = [ | ||||
|             'chill_main' => [], | ||||
|         ]; | ||||
|  | ||||
|         $processedConfig = $processor->processConfiguration($configuration, $config); | ||||
|  | ||||
|         // top_banner should not be present when not configured | ||||
|         self::assertArrayNotHasKey('top_banner', $processedConfig); | ||||
|     } | ||||
|  | ||||
|     public function testTopBannerWithMinimalConfiguration(): void | ||||
|     { | ||||
|         $containerBuilder = new ContainerBuilder(); | ||||
|         $configuration = new Configuration([], $containerBuilder); | ||||
|         $processor = new Processor(); | ||||
|  | ||||
|         // Test with minimal top_banner configuration (only text) | ||||
|         $config = [ | ||||
|             'chill_main' => [ | ||||
|                 'top_banner' => [ | ||||
|                     'text' => [ | ||||
|                         'fr' => 'Test message', | ||||
|                     ], | ||||
|                 ], | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         $processedConfig = $processor->processConfiguration($configuration, $config); | ||||
|  | ||||
|         self::assertArrayHasKey('top_banner', $processedConfig); | ||||
|         self::assertArrayHasKey('text', $processedConfig['top_banner']); | ||||
|         self::assertSame('Test message', $processedConfig['top_banner']['text']['fr']); | ||||
|         self::assertNull($processedConfig['top_banner']['color']); | ||||
|         self::assertNull($processedConfig['top_banner']['background_color']); | ||||
|     } | ||||
| } | ||||
| @@ -11,7 +11,6 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Form\Type; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Form\Type\ScopePickerType; | ||||
| @@ -42,8 +41,7 @@ final class ScopePickerTypeTest extends TypeTestCase | ||||
|     public function estBuildOneScopeIsSuccessful() | ||||
|     { | ||||
|         $form = $this->factory->create(ScopePickerType::class, null, [ | ||||
|             'center' => new Center(), | ||||
|             'role' => 'ONE_SCOPE', | ||||
|             'reachable_scopes' => [new Scope()], | ||||
|         ]); | ||||
|  | ||||
|         $view = $form->createView(); | ||||
| @@ -54,8 +52,7 @@ final class ScopePickerTypeTest extends TypeTestCase | ||||
|     public function testBuildThreeScopesIsSuccessful() | ||||
|     { | ||||
|         $form = $this->factory->create(ScopePickerType::class, null, [ | ||||
|             'center' => new Center(), | ||||
|             'role' => 'THREE_SCOPE', | ||||
|             'reachable_scopes' => [new Scope(), new Scope(), new Scope()], | ||||
|         ]); | ||||
|  | ||||
|         $view = $form->createView(); | ||||
| @@ -66,8 +63,7 @@ final class ScopePickerTypeTest extends TypeTestCase | ||||
|     public function testBuildTwoScopesIsSuccessful() | ||||
|     { | ||||
|         $form = $this->factory->create(ScopePickerType::class, null, [ | ||||
|             'center' => new Center(), | ||||
|             'role' => 'TWO_SCOPE', | ||||
|             'reachable_scopes' => [new Scope(), new Scope()], | ||||
|         ]); | ||||
|  | ||||
|         $view = $form->createView(); | ||||
|   | ||||
| @@ -37,10 +37,5 @@ class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase | ||||
|         $actual = $this->dailyNotificationDigestCronjob->run([]); | ||||
|  | ||||
|         self::assertArrayHasKey('last_execution', $actual); | ||||
|         self::assertInstanceOf( | ||||
|             \DateTimeImmutable::class, | ||||
|             \DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']), | ||||
|             'test that the string can be converted to a date' | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,16 +12,21 @@ declare(strict_types=1); | ||||
| namespace Chill\MainBundle\Tests\Notification\Email; | ||||
|  | ||||
| use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob; | ||||
| use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage; | ||||
| use Doctrine\DBAL\Connection; | ||||
| use Doctrine\DBAL\Result; | ||||
| use Doctrine\DBAL\Statement; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Clock\MockClock; | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  * @covers \DailyNotificationDigestCronjob | ||||
|  */ | ||||
| class DailyNotificationDigestCronJobTest extends TestCase | ||||
| { | ||||
| @@ -30,6 +35,7 @@ class DailyNotificationDigestCronJobTest extends TestCase | ||||
|     private MessageBusInterface $messageBus; | ||||
|     private LoggerInterface $logger; | ||||
|     private DailyNotificationDigestCronjob $cronjob; | ||||
|     private \DateTimeImmutable $firstNow; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     { | ||||
| @@ -38,6 +44,8 @@ class DailyNotificationDigestCronJobTest extends TestCase | ||||
|         $this->messageBus = $this->createMock(MessageBusInterface::class); | ||||
|         $this->logger = $this->createMock(LoggerInterface::class); | ||||
|  | ||||
|         $this->firstNow = new \DateTimeImmutable('2024-01-02T07:15:00+00:00'); | ||||
|  | ||||
|         $this->cronjob = new DailyNotificationDigestCronjob( | ||||
|             $this->clock, | ||||
|             $this->connection, | ||||
| @@ -78,4 +86,129 @@ class DailyNotificationDigestCronJobTest extends TestCase | ||||
|             'hour 23 - should not run' => [23, false], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function testRunFirstExecutionReturnsStateAndDispatches(): array | ||||
|     { | ||||
|         // Use MockClock for deterministic time | ||||
|         $firstNow = $this->firstNow; | ||||
|         $clock = new MockClock($firstNow); | ||||
|  | ||||
|         // Mock DBAL statement/result | ||||
|         $statement = $this->createMock(Statement::class); | ||||
|         $result = $this->createMock(Result::class); | ||||
|  | ||||
|         $this->connection->method('prepare')->willReturn($statement); | ||||
|         $statement->method('bindValue')->willReturnSelf(); | ||||
|         $statement->method('executeQuery')->willReturn($result); | ||||
|  | ||||
|         $rows = [ | ||||
|             ['user_id' => 10], | ||||
|             ['user_id' => 42], | ||||
|         ]; | ||||
|         $result->method('fetchAllAssociative')->willReturn($rows); | ||||
|  | ||||
|         $dispatched = []; | ||||
|         $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$dispatched) { | ||||
|             $dispatched[] = $message; | ||||
|  | ||||
|             return new Envelope($message); | ||||
|         }); | ||||
|  | ||||
|         $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); | ||||
|         $state = $cron->run([]); | ||||
|  | ||||
|         // Assert dispatch count and message contents | ||||
|         self::assertCount(2, $dispatched); | ||||
|         $expectedLast = $firstNow->sub(new \DateInterval('P1D')); | ||||
|         foreach ($dispatched as $i => $msg) { | ||||
|             self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); | ||||
|             self::assertTrue(in_array($msg->getUserId(), [10, 42], true)); | ||||
|             self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date'); | ||||
|             self::assertEquals($expectedLast, $msg->getLastExecutionDateTime(), 'compare the last execution date'); | ||||
|         } | ||||
|  | ||||
|         // Assert returned state | ||||
|         self::assertIsArray($state); | ||||
|         self::assertArrayHasKey('last_execution', $state); | ||||
|         self::assertSame($firstNow->format(\DateTimeInterface::ATOM), $state['last_execution']); | ||||
|  | ||||
|         return $state; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @depends testRunFirstExecutionReturnsStateAndDispatches | ||||
|      */ | ||||
|     public function testRunSecondExecutionUsesPreviousState(array $previousState): void | ||||
|     { | ||||
|         $firstNow = $this->firstNow; | ||||
|         $secondNow = $firstNow->add(new \DateInterval('P1D')); | ||||
|         $clock = new MockClock($secondNow); | ||||
|  | ||||
|         // Mock DBAL for a single user this time | ||||
|         $statement = $this->createMock(Statement::class); | ||||
|         $result = $this->createMock(Result::class); | ||||
|  | ||||
|         $this->connection->method('prepare')->willReturn($statement); | ||||
|         $statement->method('bindValue')->willReturnSelf(); | ||||
|         $statement->method('executeQuery')->willReturn($result); | ||||
|  | ||||
|         $rows = [ | ||||
|             ['user_id' => 7], | ||||
|         ]; | ||||
|         $result->method('fetchAllAssociative')->willReturn($rows); | ||||
|  | ||||
|         $captured = []; | ||||
|         $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) { | ||||
|             $captured[] = $message; | ||||
|  | ||||
|             return new Envelope($message); | ||||
|         }); | ||||
|  | ||||
|         $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); | ||||
|         $cron->run($previousState); | ||||
|  | ||||
|         self::assertCount(1, $captured); | ||||
|         $msg = $captured[0]; | ||||
|         self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); | ||||
|         self::assertEquals(7, $msg->getUserId()); | ||||
|         self::assertEquals($secondNow, $msg->getCurrentDateTime(), 'compare the current date'); | ||||
|         self::assertEquals($firstNow, $msg->getLastExecutionDateTime(), 'compare the last execution date'); | ||||
|     } | ||||
|  | ||||
|     public function testRunWithInvalidExecutionState(): void | ||||
|     { | ||||
|         $firstNow = new \DateTimeImmutable('2025-10-14T10:30:00 Europe/Brussels'); | ||||
|         $previousExpected = $firstNow->sub(new \DateInterval('P1D')); | ||||
|         $clock = new MockClock($firstNow); | ||||
|  | ||||
|         // Mock DBAL for a single user this time | ||||
|         $statement = $this->createMock(Statement::class); | ||||
|         $result = $this->createMock(Result::class); | ||||
|  | ||||
|         $this->connection->method('prepare')->willReturn($statement); | ||||
|         $statement->method('bindValue')->willReturnSelf(); | ||||
|         $statement->method('executeQuery')->willReturn($result); | ||||
|  | ||||
|         $rows = [ | ||||
|             ['user_id' => 7], | ||||
|         ]; | ||||
|         $result->method('fetchAllAssociative')->willReturn($rows); | ||||
|  | ||||
|         $captured = []; | ||||
|         $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) { | ||||
|             $captured[] = $message; | ||||
|  | ||||
|             return new Envelope($message); | ||||
|         }); | ||||
|  | ||||
|         $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); | ||||
|         $cron->run(['last_execution' => 'invalid data']); | ||||
|  | ||||
|         self::assertCount(1, $captured); | ||||
|         $msg = $captured[0]; | ||||
|         self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); | ||||
|         self::assertEquals(7, $msg->getUserId()); | ||||
|         self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date'); | ||||
|         self::assertEquals($previousExpected, $msg->getLastExecutionDateTime(), 'compare the last execution date'); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,173 @@ | ||||
| <?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\Tests\Security\Authorization; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore; | ||||
| use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; | ||||
| use Symfony\Component\Workflow\DefinitionBuilder; | ||||
| use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
| use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface; | ||||
| use Symfony\Component\Workflow\Transition; | ||||
| use Symfony\Component\Workflow\Workflow; | ||||
| use Symfony\Component\Workflow\WorkflowInterface; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class EntityWorkflowAttachmentVoterTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider dataVoteOnAttribute | ||||
|      */ | ||||
|     public function testVoteOnAttribute(EntityWorkflow $entityWorkflow, int $expected): void | ||||
|     { | ||||
|         $voter = new EntityWorkflowAttachmentVoter($this->buildRegistry()); | ||||
|         $actual = $voter->vote( | ||||
|             new UsernamePasswordToken(new User(), 'default'), | ||||
|             $entityWorkflow, | ||||
|             ['CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT'], | ||||
|         ); | ||||
|         $this->assertEquals($expected, $actual); | ||||
|     } | ||||
|  | ||||
|     public static function dataVoteOnAttribute(): iterable | ||||
|     { | ||||
|         $entity = new EntityWorkflow(); | ||||
|         $entity->setWorkflowName('dummy'); | ||||
|  | ||||
|         $workflow = static::buildRegistry()->get($entity, 'dummy'); | ||||
|  | ||||
|         $dto = new WorkflowTransitionContextDTO($entity); | ||||
|         $dto->futureDestUsers[] = new User(); | ||||
|         $workflow->apply( | ||||
|             $entity, | ||||
|             'to_final_positive', | ||||
|             ['context' => $dto, | ||||
|                 'byUser' => new User(), | ||||
|                 'transition' => 'to_final_positive', | ||||
|                 'transitionAt' => new \DateTimeImmutable()], | ||||
|         ); | ||||
|         // we need to mark manually as final, as the listener is not registered | ||||
|         $entity->getCurrentStep()->setIsFinal(true); | ||||
|  | ||||
|         yield 'on final positive' => [ | ||||
|             $entity, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|  | ||||
|         $entity = new EntityWorkflow(); | ||||
|         $entity->setWorkflowName('dummy'); | ||||
|  | ||||
|         $workflow = static::buildRegistry()->get($entity, 'dummy'); | ||||
|  | ||||
|         $dto = new WorkflowTransitionContextDTO($entity); | ||||
|         $dto->futureDestUsers[] = new User(); | ||||
|         $workflow->apply( | ||||
|             $entity, | ||||
|             'to_final_negative', | ||||
|             ['context' => $dto, | ||||
|                 'byUser' => new User(), | ||||
|                 'transition' => 'to_final_negative', | ||||
|                 'transitionAt' => new \DateTimeImmutable()], | ||||
|         ); | ||||
|         // we need to mark manually as final, as the listener is not registered | ||||
|         $entity->getCurrentStep()->setIsFinal(true); | ||||
|  | ||||
|         yield 'on final negative' => [ | ||||
|             $entity, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|  | ||||
|         $entity = new EntityWorkflow(); | ||||
|         $entity->setWorkflowName('dummy'); | ||||
|  | ||||
|         $workflow = static::buildRegistry()->get($entity, 'dummy'); | ||||
|  | ||||
|         $dto = new WorkflowTransitionContextDTO($entity); | ||||
|         $dto->futureDestUsers[] = new User(); | ||||
|         $workflow->apply( | ||||
|             $entity, | ||||
|             'to_sent_external', | ||||
|             ['context' => $dto, | ||||
|                 'byUser' => new User(), | ||||
|                 'transition' => 'to_sent_external', | ||||
|                 'transitionAt' => new \DateTimeImmutable()], | ||||
|         ); | ||||
|  | ||||
|         yield 'on sent_external' => [ | ||||
|             $entity, | ||||
|             VoterInterface::ACCESS_DENIED, | ||||
|         ]; | ||||
|  | ||||
|         $entity = new EntityWorkflow(); | ||||
|         $entity->setWorkflowName('dummy'); | ||||
|  | ||||
|         yield 'on initial' => [ | ||||
|             $entity, | ||||
|             VoterInterface::ACCESS_GRANTED, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     private static function buildRegistry(): Registry | ||||
|     { | ||||
|         $builder = new DefinitionBuilder(); | ||||
|         $builder | ||||
|             ->setInitialPlaces(['initial']) | ||||
|             ->addPlaces(['initial', 'sent_external', 'final_positive', 'final_negative']) | ||||
|             ->addTransitions([ | ||||
|                 new Transition('to_final_positive', ['initial'], 'final_positive'), | ||||
|                 new Transition('to_sent_external', ['initial'], 'sent_external'), | ||||
|                 new Transition('to_final_negative', ['initial'], 'final_negative'), | ||||
|  | ||||
|             ]) | ||||
|             ->setMetadataStore( | ||||
|                 new InMemoryMetadataStore( | ||||
|                     placesMetadata: [ | ||||
|                         'sent_external' => [ | ||||
|                             'isSentExternal' => true, | ||||
|                         ], | ||||
|                         'final_positive' => [ | ||||
|                             'isFinal' => true, | ||||
|                             'isFinalPositive' => true, | ||||
|                         ], | ||||
|                         'final_negative' => [ | ||||
|                             'isFinal' => true, | ||||
|                             'isFinalPositive' => false, | ||||
|                         ], | ||||
|                     ] | ||||
|                 ) | ||||
|             ); | ||||
|  | ||||
|         $workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy'); | ||||
|         $registry = new Registry(); | ||||
|         $registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface { | ||||
|             public function supports(WorkflowInterface $workflow, object $subject): bool | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         return $registry; | ||||
|     } | ||||
| } | ||||
| @@ -11,6 +11,9 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\Workflow\Helper; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository; | ||||
| use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| @@ -148,8 +151,11 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because the user is not present as a dest user']; | ||||
|         yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because there is no workflow']; | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), | ||||
|             'force deny because the user is not present as a dest user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
| @@ -171,6 +177,9 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user was a previous user']; | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), | ||||
|             'force denied because the user was not a previous user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
| @@ -232,6 +241,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: there is a signature on a canceled workflow']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), 'force denied: the workflow is sent to an external user']; | ||||
|     } | ||||
|  | ||||
|     public function testNoWorkflow(): void | ||||
| @@ -253,7 +269,217 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|         $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); | ||||
|         $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows); | ||||
|  | ||||
|         return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); | ||||
|         $repository = $this->prophesize(EntityWorkflowAttachmentRepository::class); | ||||
|         $repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn([]); | ||||
|  | ||||
|         return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataAllowedByWorkflowReadOperationByAttachment | ||||
|      * | ||||
|      * @param list<EntityWorkflow> $entityWorkflows | ||||
|      */ | ||||
|     public function testAllowedByWorkflowReadByAttachment( | ||||
|         array $entityWorkflows, | ||||
|         User $user, | ||||
|         string $expected, | ||||
|         ?\DateTimeImmutable $atDate, | ||||
|         string $message, | ||||
|     ): void { | ||||
|         // all entities must have this workflow name, so we are ok to set it here | ||||
|         foreach ($entityWorkflows as $entityWorkflow) { | ||||
|             $entityWorkflow->setWorkflowName('dummy'); | ||||
|         } | ||||
|         $helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate); | ||||
|  | ||||
|         self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new StoredObject()), $message); | ||||
|     } | ||||
|  | ||||
|     public static function provideDataAllowedByWorkflowReadOperationByAttachment(): iterable | ||||
|     { | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because the user is not present as a dest user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user is a current user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user was a previous user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'Abstain: there is a signature for person, but the attachment is not concerned']; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider provideDataAllowedByWorkflowWriteOperationByAttachment | ||||
|      * | ||||
|      * @param list<EntityWorkflow> $entityWorkflows | ||||
|      */ | ||||
|     public function testAllowedByWorkflowWriteByAttachment( | ||||
|         array $entityWorkflows, | ||||
|         User $user, | ||||
|         string $expected, | ||||
|         ?\DateTimeImmutable $atDate, | ||||
|         string $message, | ||||
|     ): void { | ||||
|         // all entities must have this workflow name, so we are ok to set it here | ||||
|         foreach ($entityWorkflows as $entityWorkflow) { | ||||
|             $entityWorkflow->setWorkflowName('dummy'); | ||||
|         } | ||||
|         $helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate); | ||||
|  | ||||
|         self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new StoredObject()), $message); | ||||
|     } | ||||
|  | ||||
|     public static function provideDataAllowedByWorkflowWriteOperationByAttachment(): iterable | ||||
|     { | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because there is no workflow']; | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because the user is not present as a dest user (and attachment)']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user is a current user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), | ||||
|             'force grant because the user was a previous user']; | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain because the user was not a previous user']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User()); | ||||
|         $entityWorkflow->getCurrentStep()->setIsFinal(true); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), | ||||
|             'force denied: user was a previous user, but it is finalized positive']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable()); | ||||
|         $entityWorkflow->getCurrentStep()->setIsFinal(true); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: user was a previous user, it is finalized, but finalized negative']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); | ||||
|         $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: there is a signature, but not on the attachment']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: there is a signature, but the signature is not on the attachment']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futurePersonSignatures[] = new Person(); | ||||
|         $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); | ||||
|         $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); | ||||
|         $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable()); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User()); | ||||
|         $entityWorkflow->getCurrentStep()->setIsFinal(true); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), | ||||
|             'abstain: there is a signature on a canceled workflow']; | ||||
|  | ||||
|         $entityWorkflow = new EntityWorkflow(); | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestUsers[] = $user = new User(); | ||||
|         $entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user); | ||||
|  | ||||
|         yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), | ||||
|             'force denied: the workflow is sent to an external user']; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param list<EntityWorkflow> $entityWorkflows | ||||
|      */ | ||||
|     private function buildHelperForAttachment(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->getUser()->willReturn($user); | ||||
|  | ||||
|         $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); | ||||
|         $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->shouldNotBeCalled(); | ||||
|  | ||||
|         $repository = $this->prophesize(EntityWorkflowAttachmentRepository::class); | ||||
|         $attachments = []; | ||||
|         foreach ($entityWorkflows as $entityWorkflow) { | ||||
|             $attachments[] = new EntityWorkflowAttachment('dummy', ['id' => 1], $entityWorkflow, new StoredObject()); | ||||
|         } | ||||
|         $repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments); | ||||
|  | ||||
|         return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); | ||||
|     } | ||||
|  | ||||
|     private static function buildRegistry(): Registry | ||||
| @@ -261,10 +487,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase | ||||
|         $builder = new DefinitionBuilder(); | ||||
|         $builder | ||||
|             ->setInitialPlaces(['initial']) | ||||
|             ->addPlaces(['initial', 'test', 'final_positive', 'final_negative']) | ||||
|             ->addPlaces(['initial', 'test', 'sent_external', 'final_positive', 'final_negative']) | ||||
|             ->setMetadataStore( | ||||
|                 new InMemoryMetadataStore( | ||||
|                     placesMetadata: [ | ||||
|                         'sent_external' => [ | ||||
|                             'isSentExternal' => true, | ||||
|                         ], | ||||
|                         'final_positive' => [ | ||||
|                             'isFinal' => true, | ||||
|                             'isFinalPositive' => true, | ||||
|   | ||||
| @@ -11,8 +11,11 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\Workflow\Messenger; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| @@ -23,6 +26,7 @@ use Chill\ThirdPartyBundle\Entity\ThirdParty; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\Argument; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Bridge\Twig\Mime\TemplatedEmail; | ||||
| use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Mime\Address; | ||||
| @@ -39,24 +43,54 @@ class PostSendExternalMessageHandlerTest extends TestCase | ||||
|     public function testSendMessageHappyScenario(): void | ||||
|     { | ||||
|         $entityWorkflow = $this->buildEntityWorkflow(); | ||||
|  | ||||
|         // Prepare attachments (2 attachments) | ||||
|         $attachmentStoredObject1 = new StoredObject(); | ||||
|         $attachmentStoredObject2 = new StoredObject(); | ||||
|         new EntityWorkflowAttachment('generic_doc', ['id' => 1], $entityWorkflow, $attachmentStoredObject1); | ||||
|         new EntityWorkflowAttachment('generic_doc', ['id' => 2], $entityWorkflow, $attachmentStoredObject2); | ||||
|  | ||||
|         // Prepare transition DTO and sends | ||||
|         $dto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $dto->futureDestineeEmails = ['external@example.com']; | ||||
|         $dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')]; | ||||
|         $entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User()); | ||||
|  | ||||
|         // Repository returns our workflow | ||||
|         $repository = $this->prophesize(EntityWorkflowRepository::class); | ||||
|         $repository->find(1)->willReturn($entityWorkflow); | ||||
|  | ||||
|         // Mailer must send to both recipients | ||||
|         $mailer = $this->prophesize(MailerInterface::class); | ||||
|         $mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce(); | ||||
|         $mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce(); | ||||
|  | ||||
|         // Workflow manager and handler | ||||
|         $workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class); | ||||
|         $workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title'); | ||||
|         $workflowManager = $this->prophesize(EntityWorkflowManager::class); | ||||
|         $workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal()); | ||||
|  | ||||
|         $handler = new PostSendExternalMessageHandler($repository->reveal(), $mailer->reveal(), $workflowManager->reveal()); | ||||
|         // Associated stored object for the workflow | ||||
|         $associatedStoredObject = new StoredObject(); | ||||
|         $workflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($associatedStoredObject); | ||||
|  | ||||
|         // Converter should be called for each attachment and the associated stored object | ||||
|         $converter = $this->prophesize(StoredObjectToPdfConverter::class); | ||||
|         $converter->addConvertedVersion($attachmentStoredObject1, 'fr')->shouldBeCalledOnce(); | ||||
|         $converter->addConvertedVersion($attachmentStoredObject2, 'fr')->shouldBeCalledOnce(); | ||||
|         $converter->addConvertedVersion($associatedStoredObject, 'fr')->shouldBeCalledOnce(); | ||||
|  | ||||
|         // Logger (not used in happy path, but required by handler) | ||||
|         $logger = $this->prophesize(LoggerInterface::class); | ||||
|  | ||||
|         $handler = new PostSendExternalMessageHandler( | ||||
|             $repository->reveal(), | ||||
|             $mailer->reveal(), | ||||
|             $workflowManager->reveal(), | ||||
|             $converter->reveal(), | ||||
|             $logger->reveal(), | ||||
|         ); | ||||
|  | ||||
|         $handler(new PostSendExternalMessage(1, 'fr')); | ||||
|  | ||||
|   | ||||
| @@ -11,9 +11,12 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Workflow\Helper; | ||||
|  | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| @@ -58,21 +61,39 @@ class WorkflowRelatedEntityPermissionHelper | ||||
|     public function __construct( | ||||
|         private readonly Security $security, | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|         private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository, | ||||
|         private readonly Registry $registry, | ||||
|         private readonly ClockInterface $clock, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * @param object $entity The entity may be an | ||||
|      * | ||||
|      * @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN' | ||||
|      */ | ||||
|     public function isAllowedByWorkflowForReadOperation(object $entity): string | ||||
|     { | ||||
|         $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|         if ($entity instanceof StoredObject) { | ||||
|             $attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity); | ||||
|             $entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments); | ||||
|             $isAttached = true; | ||||
|         } else { | ||||
|             $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|             $isAttached = false; | ||||
|         } | ||||
|  | ||||
|         if ([] === $entityWorkflows) { | ||||
|             return self::ABSTAIN; | ||||
|         } | ||||
|  | ||||
|         if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) { | ||||
|             return self::FORCE_GRANT; | ||||
|         } | ||||
|  | ||||
|         if ($isAttached) { | ||||
|             return self::ABSTAIN; | ||||
|         } | ||||
|  | ||||
|         // give a view permission if there is a Person signature pending, or in the 12 hours following | ||||
|         // the signature last state | ||||
|         foreach ($entityWorkflows as $workflow) { | ||||
| @@ -100,33 +121,51 @@ class WorkflowRelatedEntityPermissionHelper | ||||
|      */ | ||||
|     public function isAllowedByWorkflowForWriteOperation(object $entity): string | ||||
|     { | ||||
|         $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|         $runningWorkflows = []; | ||||
|         if ($entity instanceof StoredObject) { | ||||
|             $attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity); | ||||
|             $entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments); | ||||
|             $isAttached = true; | ||||
|         } else { | ||||
|             $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); | ||||
|             $isAttached = false; | ||||
|         } | ||||
|  | ||||
|         // if a workflow is finalized positive, we are not allowed to edit to document any more | ||||
|         if ([] === $entityWorkflows) { | ||||
|             return self::ABSTAIN; | ||||
|         } | ||||
|  | ||||
|         // if a workflow is finalized positive, anyone is allowed to edit the document anymore | ||||
|         foreach ($entityWorkflows as $entityWorkflow) { | ||||
|             if ($entityWorkflow->isFinal()) { | ||||
|                 $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); | ||||
|                 $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); | ||||
|                 foreach ($marking->getPlaces() as $place => $int) { | ||||
|                     $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); | ||||
|                     if (true === ($placeMetadata['isFinalPositive'] ?? false)) { | ||||
|                         // the workflow is final, and final positive, so we stop here. | ||||
|                         return self::FORCE_DENIED; | ||||
|                     } | ||||
|             $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); | ||||
|             $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); | ||||
|             foreach ($marking->getPlaces() as $place => $int) { | ||||
|                 $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); | ||||
|                 if ( | ||||
|                     ($entityWorkflow->isFinal() && ($placeMetadata['isFinalPositive'] ?? false)) | ||||
|                     || ($placeMetadata['isSentExternal'] ?? false) | ||||
|                 ) { | ||||
|                     // the workflow is final, and final positive, or is sentExternal, so we stop here. | ||||
|                     return self::FORCE_DENIED; | ||||
|                 } | ||||
|                 if ( | ||||
|                     // if not finalized positive | ||||
|                     $entityWorkflow->isFinal() && !($placeMetadata['isFinalPositive'] ?? false) | ||||
|                 ) { | ||||
|                     return self::ABSTAIN; | ||||
|                 } | ||||
|             } else { | ||||
|                 $runningWorkflows[] = $entityWorkflow; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // if there is a signature on a **running workflow**, no one can edit the workflow any more | ||||
|         foreach ($runningWorkflows as $entityWorkflow) { | ||||
|             foreach ($entityWorkflow->getSteps() as $step) { | ||||
|                 foreach ($step->getSignatures() as $signature) { | ||||
|                     if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { | ||||
|                         return self::FORCE_DENIED; | ||||
|         $runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal()); | ||||
|  | ||||
|         // if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore | ||||
|         if (!$isAttached) { | ||||
|             foreach ($runningWorkflows as $entityWorkflow) { | ||||
|                 foreach ($entityWorkflow->getSteps() as $step) { | ||||
|                     foreach ($step->getSignatures() as $signature) { | ||||
|                         if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { | ||||
|                             return self::FORCE_DENIED; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| @@ -137,7 +176,11 @@ class WorkflowRelatedEntityPermissionHelper | ||||
|             return self::FORCE_GRANT; | ||||
|         } | ||||
|  | ||||
|         return self::ABSTAIN; | ||||
|         if ($isAttached) { | ||||
|             return self::ABSTAIN; | ||||
|         } | ||||
|  | ||||
|         return self::FORCE_DENIED; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -11,9 +11,14 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Workflow\Messenger; | ||||
|  | ||||
| use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException; | ||||
| use Chill\DocStoreBundle\Exception\StoredObjectManagerException; | ||||
| use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend; | ||||
| use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Bridge\Twig\Mime\TemplatedEmail; | ||||
| use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; | ||||
| @@ -25,6 +30,8 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt | ||||
|         private EntityWorkflowRepository $entityWorkflowRepository, | ||||
|         private MailerInterface $mailer, | ||||
|         private EntityWorkflowManager $workflowManager, | ||||
|         private StoredObjectToPdfConverter $storedObjectToPdfConverter, | ||||
|         private LoggerInterface $logger, | ||||
|     ) {} | ||||
|  | ||||
|     public function __invoke(PostSendExternalMessage $message): void | ||||
| @@ -35,11 +42,38 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt | ||||
|             throw new UnrecoverableMessageHandlingException(sprintf('Entity workflow with id %d not found', $message->entityWorkflowId)); | ||||
|         } | ||||
|  | ||||
|         $this->convertToPdf($entityWorkflow, $message->lang); | ||||
|  | ||||
|         foreach ($entityWorkflow->getCurrentStep()->getSends() as $send) { | ||||
|             $this->sendEmailToDestinee($send, $message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private function convertToPdf(EntityWorkflow $entityWorkflow, string $locale): void | ||||
|     { | ||||
|         foreach ($entityWorkflow->getAttachments() as $attachment) { | ||||
|             try { | ||||
|                 $this->storedObjectToPdfConverter->addConvertedVersion($attachment->getProxyStoredObject(), $locale); | ||||
|             } catch (StoredObjectManagerException $e) { | ||||
|                 $this->logger->error('Error converting attachment to PDF', ['backtrace' => $e->getTraceAsString(), 'attachment_id' => $attachment->getId()]); | ||||
|             } catch (ConversionWithSameMimeTypeException $e) { | ||||
|                 $this->logger->error('Error converting attachment to PDF: already at the same MIME type', ['backtrace' => $e->getTraceAsString(), 'attachment_id' => $attachment->getId()]); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $storedObject = $this->workflowManager->getAssociatedStoredObject($entityWorkflow); | ||||
|  | ||||
|         if (null !== $storedObject) { | ||||
|             try { | ||||
|                 $this->storedObjectToPdfConverter->addConvertedVersion($storedObject, $locale); | ||||
|             } catch (StoredObjectManagerException $e) { | ||||
|                 $this->logger->error('Error converting stored object to PDF', ['backtrace' => $e->getTraceAsString(), 'stored_object_id' => $storedObject->getId(), 'workflow_id' => $entityWorkflow->getId()]); | ||||
|             } catch (ConversionWithSameMimeTypeException $e) { | ||||
|                 $this->logger->error('Error converting stored object to PDF: already at the same MIME type', ['backtrace' => $e->getTraceAsString(), 'stored_object_id' => $storedObject->getId(), 'workflow_id' => $entityWorkflow->getId()]); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private function sendEmailToDestinee(EntityWorkflowSend $send, PostSendExternalMessage $message): void | ||||
|     { | ||||
|         $entityWorkflow = $send->getEntityWorkflowStep()->getEntityWorkflow(); | ||||
|   | ||||
| @@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
| use Symfony\Component\Workflow\Transition; | ||||
|  | ||||
| /** | ||||
|  * Handles state changes for signature steps within a workflow. | ||||
| @@ -50,8 +51,10 @@ class SignatureStepStateChanger | ||||
|      * | ||||
|      * @param EntityWorkflowStepSignature $signature the signature entity to be marked as signed | ||||
|      * @param int|null                    $atIndex   optional index position for the signature within the zone | ||||
|      * | ||||
|      * @return string The expected new workflow's step, after transition is applyied | ||||
|      */ | ||||
|     public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): void | ||||
|     public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): string | ||||
|     { | ||||
|         $this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE); | ||||
|  | ||||
| @@ -60,7 +63,14 @@ class SignatureStepStateChanger | ||||
|             ->setZoneSignatureIndex($atIndex) | ||||
|             ->setStateDate($this->clock->now()); | ||||
|         $this->logger->info(self::LOG_PREFIX.'Mark signature entity as signed', ['signatureId' => $signature->getId(), 'index' => (string) $atIndex]); | ||||
|         ['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature); | ||||
|  | ||||
|         $this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId())); | ||||
|         if (null === $transition) { | ||||
|             return $signature->getStep()->getEntityWorkflow()->getStep(); | ||||
|         } | ||||
|  | ||||
|         return $transition->getTos()[0]; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -71,8 +81,10 @@ class SignatureStepStateChanger | ||||
|      * | ||||
|      * This method updates the signature state to 'canceled' and logs the action. | ||||
|      * It also dispatches a message to notify about the state change. | ||||
|      * | ||||
|      * @return string The expected new workflow's step, after transition is applyied | ||||
|      */ | ||||
|     public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): void | ||||
|     public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): string | ||||
|     { | ||||
|         $this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE); | ||||
|  | ||||
| @@ -80,7 +92,15 @@ class SignatureStepStateChanger | ||||
|             ->setState(EntityWorkflowSignatureStateEnum::CANCELED) | ||||
|             ->setStateDate($this->clock->now()); | ||||
|         $this->logger->info(self::LOG_PREFIX.'Mark signature entity as canceled', ['signatureId' => $signature->getId()]); | ||||
|  | ||||
|         ['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature); | ||||
|  | ||||
|         $this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId())); | ||||
|         if (null === $transition) { | ||||
|             return $signature->getStep()->getEntityWorkflow()->getStep(); | ||||
|         } | ||||
|  | ||||
|         return $transition->getTos()[0]; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -93,8 +113,10 @@ class SignatureStepStateChanger | ||||
|      * a state change has occurred. | ||||
|      * | ||||
|      * @param EntityWorkflowStepSignature $signature the signature entity to be marked as rejected | ||||
|      * | ||||
|      * @return string The expected new workflow's step, after transition is applyied | ||||
|      */ | ||||
|     public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): void | ||||
|     public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): string | ||||
|     { | ||||
|         $this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE); | ||||
|  | ||||
| @@ -102,7 +124,16 @@ class SignatureStepStateChanger | ||||
|             ->setState(EntityWorkflowSignatureStateEnum::REJECTED) | ||||
|             ->setStateDate($this->clock->now()); | ||||
|         $this->logger->info(self::LOG_PREFIX.'Mark signature entity as rejected', ['signatureId' => $signature->getId()]); | ||||
|  | ||||
|         ['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature); | ||||
|  | ||||
|         $this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId())); | ||||
|  | ||||
|         if (null === $transition) { | ||||
|             return $signature->getStep()->getEntityWorkflow()->getStep(); | ||||
|         } | ||||
|  | ||||
|         return $transition->getTos()[0]; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -117,10 +148,35 @@ class SignatureStepStateChanger | ||||
|     { | ||||
|         $this->entityManager->refresh($signature, LockMode::PESSIMISTIC_READ); | ||||
|  | ||||
|         ['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature); | ||||
|  | ||||
|         if (null === $transition) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $entityWorkflow = $signature->getStep()->getEntityWorkflow(); | ||||
|         $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); | ||||
|         $transitionDto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $transitionDto->futureDestUsers[] = $futureUser; | ||||
|  | ||||
|         $workflow->apply($entityWorkflow, $transition->getName(), [ | ||||
|             'context' => $transitionDto, | ||||
|             'transitionAt' => $this->clock->now(), | ||||
|             'transition' => $transition->getName(), | ||||
|         ]); | ||||
|  | ||||
|         $this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return array{transition: Transition|null, futureUser: User|null} | ||||
|      */ | ||||
|     private function decideTransition(EntityWorkflowStepSignature $signature): array | ||||
|     { | ||||
|         if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) { | ||||
|             $this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]); | ||||
|  | ||||
|             return; | ||||
|             return ['transition' => null, 'futureUser' => null]; | ||||
|         } | ||||
|  | ||||
|         $this->logger->debug(self::LOG_PREFIX.'Continuing the process to find a transition', ['signatureId' => $signature->getId()]); | ||||
| @@ -144,7 +200,7 @@ class SignatureStepStateChanger | ||||
|         if (null === $transition) { | ||||
|             $this->logger->info(self::LOG_PREFIX.'The transition is not configured, will not apply a transition', ['signatureId' => $signature->getId()]); | ||||
|  | ||||
|             return; | ||||
|             return ['transition' => null, 'futureUser' => null]; | ||||
|         } | ||||
|  | ||||
|         if ('person' === $signature->getSignerKind()) { | ||||
| @@ -156,19 +212,16 @@ class SignatureStepStateChanger | ||||
|         if (null === $futureUser) { | ||||
|             $this->logger->info(self::LOG_PREFIX.'No previous user, will not apply a transition', ['signatureId' => $signature->getId()]); | ||||
|  | ||||
|             return; | ||||
|             return ['transition' => null, 'futureUser' => null]; | ||||
|         } | ||||
|  | ||||
|         $transitionDto = new WorkflowTransitionContextDTO($entityWorkflow); | ||||
|         $transitionDto->futureDestUsers[] = $futureUser; | ||||
|         foreach ($workflow->getDefinition()->getTransitions() as $transitionObj) { | ||||
|             if ($transitionObj->getName() === $transition) { | ||||
|                 return ['transition' => $transitionObj, 'futureUser' => $futureUser]; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         $workflow->apply($entityWorkflow, $transition, [ | ||||
|             'context' => $transitionDto, | ||||
|             'transitionAt' => $this->clock->now(), | ||||
|             'transition' => $transition, | ||||
|         ]); | ||||
|  | ||||
|         $this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]); | ||||
|         throw new \RuntimeException('Transition not found'); | ||||
|     } | ||||
|  | ||||
|     private function getPreviousSender(EntityWorkflowStep $entityWorkflowStep): ?User | ||||
|   | ||||
| @@ -965,6 +965,31 @@ paths: | ||||
|                         application/json: | ||||
|                             schema: | ||||
|                                 $ref: "#/components/schemas/UserJob" | ||||
|     /1.0/main/workflow/{id}.json: | ||||
|         get: | ||||
|             tags: | ||||
|                 - workflow | ||||
|             summary: Return a workflow | ||||
|             parameters: | ||||
|                 -   name: id | ||||
|                     in: path | ||||
|                     required: true | ||||
|                     description: The workflow id | ||||
|                     schema: | ||||
|                         type: integer | ||||
|                         format: integer | ||||
|                         minimum: 1 | ||||
|             responses: | ||||
|                 200: | ||||
|                     description: "ok" | ||||
|                     content: | ||||
|                         application/json: | ||||
|                             schema: | ||||
|                                 $ref: "#/components/schemas/Workflow" | ||||
|                 404: | ||||
|                     description: "not found" | ||||
|                 401: | ||||
|                     description: "Unauthorized" | ||||
|     /1.0/main/workflow/my: | ||||
|         get: | ||||
|             tags: | ||||
|   | ||||
| @@ -120,5 +120,8 @@ module.exports = function (encore, entries) { | ||||
|     "vue_onthefly", | ||||
|     __dirname + "/Resources/public/vuejs/OnTheFly/index.js", | ||||
|   ); | ||||
|  | ||||
|   encore.addEntry( | ||||
|     "page_workflow_waiting_post_process", | ||||
|     __dirname + "/Resources/public/vuejs/WaitPostProcessWorkflow/index.ts" | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -666,9 +666,18 @@ workflow: | ||||
|         cancel_are_you_sure: Êtes-vous sûr de vouloir annuler la signature de %signer% | ||||
|         reject_signature_of: Rejet de la signature de %signer% | ||||
|         reject_are_you_sure: Êtes-vous sûr de vouloir rejeter la signature de %signer% | ||||
|         waiting_for: En attente de modification de l'état de la signature | ||||
|  | ||||
|     attachments: | ||||
|         title: Pièces jointes | ||||
|         no_attachment: Aucune pièce jointe | ||||
|         Add_an_attachment: Ajouter une pièce jointe | ||||
|  | ||||
|     wait: | ||||
|         title: En attente de traitement | ||||
|         error_while_waiting: Le traitement a échoué | ||||
|         success: Traitement terminé. Redirection en cours... | ||||
|  | ||||
|  | ||||
| Subscribe final: Recevoir une notification à l'étape finale | ||||
| Subscribe all steps: Recevoir une notification à chaque étape | ||||
|   | ||||
| @@ -49,7 +49,7 @@ final class ImportSocialWorkMetadata extends Command | ||||
|         $filepath = $input->getOption('filepath'); | ||||
|  | ||||
|         try { | ||||
|             $csv = Reader::createFromPath($filepath); | ||||
|             $csv = Reader::from($filepath); | ||||
|         } catch (\Throwable $e) { | ||||
|             throw new \Exception('Error while loading CSV.', 0, $e); | ||||
|         } | ||||
|   | ||||
| @@ -29,7 +29,7 @@ class LoadSocialWorkMetadata extends Fixture implements OrderedFixtureInterface | ||||
|     public function load(ObjectManager $manager): void | ||||
|     { | ||||
|         try { | ||||
|             $csv = Reader::createFromPath(__DIR__.'/data/social_work_metadata.csv'); | ||||
|             $csv = Reader::from(__DIR__.'/data/social_work_metadata.csv'); | ||||
|         } catch (\Throwable $e) { | ||||
|             throw new \Exception('Error while loading CSV.', 0, $e); | ||||
|         } | ||||
|   | ||||
| @@ -60,6 +60,7 @@ import { | ||||
|     EVALUATION_DOCUMENT_MOVE_SUCCESS, | ||||
| } from "translator"; | ||||
| import { useToast } from "vue-toast-notification"; | ||||
| import { buildLinkCreate as buildLinkCreateNotification } from "ChillMainAssets/lib/entity-notification/api"; | ||||
|  | ||||
| const props = defineProps(["evaluation", "docAnchorId"]); | ||||
| const store = useStore(); | ||||
|   | ||||
| @@ -167,10 +167,20 @@ final readonly class PersonContext implements PersonContextInterface | ||||
|         } | ||||
|  | ||||
|         if ($this->isScopeNecessary($entity)) { | ||||
|             $reachableScopes = array_values( | ||||
|                 array_filter( | ||||
|                     $this->authorizationHelper->getReachableScopes( | ||||
|                         $this->security->getUser(), | ||||
|                         PersonDocumentVoter::CREATE, | ||||
|                         $this->centerResolverManager->resolveCenters($entity) | ||||
|                     ), | ||||
|                     static fn (Scope $s) => $s->isActive() | ||||
|                 ) | ||||
|             ); | ||||
|  | ||||
|             $builder->add('scope', ScopePickerType::class, [ | ||||
|                 'center' => $this->centerResolverManager->resolveCenters($entity), | ||||
|                 'role' => PersonDocumentVoter::CREATE, | ||||
|                 'label' => 'Scope', | ||||
|                 'reachable_scopes' => $reachableScopes, | ||||
|                 'label' => count($reachableScopes) > 1 ? 'Scope' : false, | ||||
|             ]); | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -38,7 +38,7 @@ final readonly class SocialActionCSVExportService | ||||
|             array_keys($this->formatRow(new SocialAction())) | ||||
|         ); | ||||
|  | ||||
|         $csv = Writer::createFromPath('php://temp', 'w+'); | ||||
|         $csv = Writer::from('php://temp', 'w+'); | ||||
|         $csv->insertOne($headers); | ||||
|  | ||||
|         foreach ($actions as $action) { | ||||
|   | ||||
| @@ -36,7 +36,7 @@ readonly class SocialIssueCSVExportService | ||||
|     public function generateCsv(array $issues): Writer | ||||
|     { | ||||
|         // CSV headers | ||||
|         $csv = Writer::createFromPath('php://temp', 'r+'); | ||||
|         $csv = Writer::from('php://temp', 'r+'); | ||||
|         $csv->insertOne( | ||||
|             array_map( | ||||
|                 fn (string $e) => $this->translator->trans($e), | ||||
|   | ||||
| @@ -11,11 +11,13 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\TaskBundle\Form; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Scope; | ||||
| use Chill\MainBundle\Form\Type\ChillDateType; | ||||
| use Chill\MainBundle\Form\Type\ChillTextareaType; | ||||
| use Chill\MainBundle\Form\Type\DateIntervalType; | ||||
| use Chill\MainBundle\Form\Type\ScopePickerType; | ||||
| use Chill\MainBundle\Form\Type\UserPickerType; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; | ||||
| use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface; | ||||
| use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; | ||||
| use Chill\TaskBundle\Security\Authorization\TaskVoter; | ||||
| @@ -24,10 +26,18 @@ use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\TextType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| class SingleTaskType extends AbstractType | ||||
| { | ||||
|     public function __construct(private readonly ParameterBagInterface $parameterBag, private readonly CenterResolverDispatcherInterface $centerResolverDispatcher, private readonly ScopeResolverDispatcher $scopeResolverDispatcher) {} | ||||
|     public function __construct( | ||||
|         private readonly ParameterBagInterface $parameterBag, | ||||
|         private readonly CenterResolverDispatcherInterface $centerResolverDispatcher, | ||||
|         private readonly Security $security, | ||||
|         private readonly AuthorizationHelperInterface $authorizationHelper, | ||||
|         private readonly ScopeResolverDispatcher $scopeResolverDispatcher, | ||||
|     ) {} | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
|     { | ||||
| @@ -62,11 +72,22 @@ class SingleTaskType extends AbstractType | ||||
|             ]); | ||||
|  | ||||
|         if ($isScopeConcerned && $this->parameterBag->get('chill_main')['acl']['form_show_scopes']) { | ||||
|             $reachableScopes = array_values( | ||||
|                 array_filter( | ||||
|                     $this->authorizationHelper->getReachableScopes( | ||||
|                         $this->security->getUser(), | ||||
|                         $options['role'], | ||||
|                         $center | ||||
|                     ), | ||||
|                     static fn (Scope $s) => $s->isActive() | ||||
|                 ) | ||||
|             ); | ||||
|  | ||||
|             $builder | ||||
|                 ->add('circle', ScopePickerType::class, [ | ||||
|                     'center' => $center, | ||||
|                     'role' => $options['role'], | ||||
|                     'reachable_scopes' => $reachableScopes, | ||||
|                     'required' => true, | ||||
|                     'label' => count($reachableScopes) > 1 ? 'Scope' : false, | ||||
|                 ]); | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -52,7 +52,7 @@ class ThirdpartyCSVExportController extends AbstractController | ||||
|             fwrite($output, "\xEF\xBB\xBF"); | ||||
|  | ||||
|             // Create CSV writer | ||||
|             $csv = Writer::createFromStream($output); | ||||
|             $csv = Writer::from($output); | ||||
|  | ||||
|             // Write header row | ||||
|             $header = array_map( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user