From 77d06d756a3dfc3c7947f82a549d50c42cd323b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 19 Sep 2024 14:24:47 +0200 Subject: [PATCH 1/8] Block document editing if any signature associated to a workflow is signed Add a check in `WorkflowStoredObjectPermissionHelper` to block document editing once any signature is signed. Accompanied by new tests to verify this behavior. --- .../WorkflowStoredObjectPermissionHelper.php | 11 ++ ...rkflowStoredObjectPermissionHelperTest.php | 101 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Service/WorkflowStoredObjectPermissionHelperTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Service/WorkflowStoredObjectPermissionHelper.php b/src/Bundle/ChillDocStoreBundle/Service/WorkflowStoredObjectPermissionHelper.php index b27b6d96a..545325121 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/WorkflowStoredObjectPermissionHelper.php +++ b/src/Bundle/ChillDocStoreBundle/Service/WorkflowStoredObjectPermissionHelper.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Service; +use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Workflow\EntityWorkflowManager; use Symfony\Component\Security\Core\Security; @@ -31,6 +32,16 @@ class WorkflowStoredObjectPermissionHelper if (!$workflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) { return false; } + + // as soon as there is one signatured applyied, we are not able to + // edit the document any more + foreach ($workflow->getSteps() as $step) { + foreach ($step->getSignatures() as $signature) { + if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { + return false; + } + } + } } return true; diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/WorkflowStoredObjectPermissionHelperTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/WorkflowStoredObjectPermissionHelperTest.php new file mode 100644 index 000000000..e87ea4653 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/WorkflowStoredObjectPermissionHelperTest.php @@ -0,0 +1,101 @@ +buildHelper($object, $entityWorkflow, $user); + + self::assertEquals($expected, $helper->notBlockedByWorkflow($entityWorkflow), $message); + } + + private function buildHelper(object $relatedEntity, EntityWorkflow $entityWorkflow, User $user): WorkflowStoredObjectPermissionHelper + { + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn($user); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn([$entityWorkflow]); + + return new WorkflowStoredObjectPermissionHelper($security->reveal(), $entityWorkflowManager->reveal()); + } + + public static function provideDataNotBlockByWorkflow(): iterable + { + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable()); + + yield [$entityWorkflow, new User(), false, 'blocked 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(), $user); + + yield [$entityWorkflow, $user, true, 'allowed because the user is 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(), $user); + $entityWorkflow->getCurrentStep()->setIsFinal(true); + + yield [$entityWorkflow, $user, false, 'blocked because the step is final']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); + $step = $entityWorkflow->getCurrentStep(); + new EntityWorkflowStepSignature($step, new Person()); + + yield [$entityWorkflow, $user, true, 'allow, a signature is present but still pending']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); + $step = $entityWorkflow->getCurrentStep(); + $signature = new EntityWorkflowStepSignature($step, new Person()); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED); + + yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed']; + + } +} From 5f67a7aadc20c5c1fcb7698508b11337de5450e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 19 Sep 2024 17:47:55 +0200 Subject: [PATCH 2/8] Create a service to duplicate a storedObject into another one --- .../Service/StoredObjectDuplicate.php | 47 ++++++++++++++++++ .../Service/StoredObjectRestore.php | 3 ++ .../Service/StoredObjectDuplicateTest.php | 48 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/Bundle/ChillDocStoreBundle/Service/StoredObjectDuplicate.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectDuplicateTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectDuplicate.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectDuplicate.php new file mode 100644 index 000000000..b1c7e7a87 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectDuplicate.php @@ -0,0 +1,47 @@ +getCurrentVersion(); + + $oldContent = $this->storedObjectManager->read($fromVersion); + + $storedObject = new StoredObject(); + + $newVersion = $this->storedObjectManager->write($storedObject, $oldContent, $fromVersion->getType()); + + $newVersion->setCreatedFrom($fromVersion); + + $this->logger->info('[StoredObjectDuplicate] Duplicated stored object from a version of a previous stored object', [ + 'from_stored_object_uuid' => $fromVersion->getStoredObject()->getUuid(), + 'to_stored_object_uuid' => $storedObject->getUuid(), + 'old_version_id' => $fromVersion->getId(), + 'old_version_version' => $fromVersion->getVersion(), + 'new_version_id' => $newVersion->getVersion(), + ]); + + return $storedObject; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestore.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestore.php index 3c6469185..bad032346 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestore.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestore.php @@ -14,6 +14,9 @@ namespace Chill\DocStoreBundle\Service; use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Psr\Log\LoggerInterface; +/** + * Class responsible for restoring stored object versions into the same stored object. + */ final readonly class StoredObjectRestore implements StoredObjectRestoreInterface { public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectDuplicateTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectDuplicateTest.php new file mode 100644 index 000000000..2e9da0bab --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectDuplicateTest.php @@ -0,0 +1,48 @@ +registerVersion(type: $type = 'application/test'); + + $manager = $this->createMock(StoredObjectManagerInterface::class); + $manager->method('read')->with($version)->willReturn('1234'); + $manager + ->expects($this->once()) + ->method('write') + ->with($this->isInstanceOf(StoredObject::class), '1234', 'application/test') + ->willReturnCallback(fn (StoredObject $so, $content, $type) => $so->registerVersion(type: $type)); + + $storedObjectDuplicate = new StoredObjectDuplicate($manager, new NullLogger()); + + $actual = $storedObjectDuplicate->duplicate($storedObject); + + self::assertNotNull($actual->getCurrentVersion()); + self::assertNotNull($actual->getCurrentVersion()->getCreatedFrom()); + self::assertSame($version, $actual->getCurrentVersion()->getCreatedFrom()); + } +} From a8c5d1f66049725b896fc1586f9e76ff152f0967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 19 Sep 2024 17:48:34 +0200 Subject: [PATCH 3/8] Remove unused constructor parameter --- .../Workflow/Templating/WorkflowTwigExtensionRuntime.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Workflow/Templating/WorkflowTwigExtensionRuntime.php b/src/Bundle/ChillMainBundle/Workflow/Templating/WorkflowTwigExtensionRuntime.php index f2b7d80db..332ccd2e5 100644 --- a/src/Bundle/ChillMainBundle/Workflow/Templating/WorkflowTwigExtensionRuntime.php +++ b/src/Bundle/ChillMainBundle/Workflow/Templating/WorkflowTwigExtensionRuntime.php @@ -13,7 +13,6 @@ namespace Chill\MainBundle\Workflow\Templating; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; -use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Workflow\Registry; @@ -23,7 +22,7 @@ use Twig\Extension\RuntimeExtensionInterface; class WorkflowTwigExtensionRuntime implements RuntimeExtensionInterface { - public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly Registry $registry, private readonly EntityWorkflowRepository $repository, private readonly MetadataExtractor $metadataExtractor, private readonly NormalizerInterface $normalizer) {} + public function __construct(private readonly Registry $registry, private readonly EntityWorkflowRepository $repository, private readonly MetadataExtractor $metadataExtractor, private readonly NormalizerInterface $normalizer) {} public function getTransitionByString(EntityWorkflow $entityWorkflow, string $key): ?Transition { From 4b65ec9b5406aae58eb5cef8b239868c29f1d659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 19 Sep 2024 22:44:06 +0200 Subject: [PATCH 4/8] Create a duplicator service for accompanying course document --- ...companyingCourseDocumentDuplicatorTest.php | 72 +++++++++++++++++++ .../AccompanyingCourseDocumentDuplicator.php | 48 +++++++++++++ ...ompanyingCourseDocumentWorkflowHandler.php | 5 +- .../translations/messages+intl-icu.fr.yml | 3 + 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Workflow/AccompanyingCourseDocumentDuplicatorTest.php create mode 100644 src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentDuplicator.php create mode 100644 src/Bundle/ChillDocStoreBundle/translations/messages+intl-icu.fr.yml diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Workflow/AccompanyingCourseDocumentDuplicatorTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Workflow/AccompanyingCourseDocumentDuplicatorTest.php new file mode 100644 index 000000000..fd920c7a9 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Workflow/AccompanyingCourseDocumentDuplicatorTest.php @@ -0,0 +1,72 @@ +setDate($date = new \DateTimeImmutable()) + ->setObject($object) + ->setTitle('Title') + ->setUser($user = new User()) + ->setCategory($category = new DocumentCategory('bundle', 10)) + ->setDescription($description = 'Description'); + + $actual = $this->buildDuplicator()->duplicate($document); + + self::assertSame($date, $actual->getDate()); + // FYI, the duplication of object is checked by the mock + self::assertNotNull($actual->getObject()); + self::assertStringStartsWith('Title', $actual->getTitle()); + self::assertSame($user, $actual->getUser()); + self::assertSame($category, $actual->getCategory()); + self::assertEquals($description, $actual->getDescription()); + } + + private function buildDuplicator(): AccompanyingCourseDocumentDuplicator + { + $storedObjectDuplicate = $this->createMock(StoredObjectDuplicate::class); + $storedObjectDuplicate->expects($this->once())->method('duplicate') + ->with($this->isInstanceOf(StoredObject::class))->willReturn(new StoredObject()); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->withAnyParameters()->willReturn('duplicated'); + $clock = new MockClock(); + + return new AccompanyingCourseDocumentDuplicator( + $storedObjectDuplicate, + $translator, + $clock + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentDuplicator.php b/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentDuplicator.php new file mode 100644 index 000000000..b4c15fc22 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentDuplicator.php @@ -0,0 +1,48 @@ +setCourse($document->getCourse()) + ->setTitle($document->getTitle().' ('.$this->translator->trans('acc_course_document.duplicated_at', ['at' => $this->clock->now()]).')') + ->setDate($document->getDate()) + ->setDescription($document->getDescription()) + ->setCategory($document->getCategory()) + ->setUser($document->getUser()) + ->setObject($this->storedObjectDuplicate->duplicate($document->getObject())) + ; + + return $newDoc; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php b/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php index bc8de6587..ba7c477e5 100644 --- a/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php +++ b/src/Bundle/ChillDocStoreBundle/Workflow/AccompanyingCourseDocumentWorkflowHandler.php @@ -17,6 +17,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; +use Chill\MainBundle\Workflow\EntityWorkflowWithDuplicableRelatedEntityInterface; use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface; use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; use Symfony\Contracts\Translation\TranslatorInterface; @@ -27,8 +28,8 @@ use Symfony\Contracts\Translation\TranslatorInterface; readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface { public function __construct( - private TranslatorInterface $translator, - private EntityWorkflowRepository $workflowRepository, + private TranslatorInterface $translator, + private EntityWorkflowRepository $workflowRepository, private AccompanyingCourseDocumentRepository $repository, ) {} diff --git a/src/Bundle/ChillDocStoreBundle/translations/messages+intl-icu.fr.yml b/src/Bundle/ChillDocStoreBundle/translations/messages+intl-icu.fr.yml new file mode 100644 index 000000000..0132cdc4f --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/translations/messages+intl-icu.fr.yml @@ -0,0 +1,3 @@ +acc_course_document: + duplicated_at: >- + Dupliqué le {at, date, long} à {at, time, short} From 20e8b03588b200b01c7c282f99f91b3fa050f9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 23 Sep 2024 12:51:22 +0200 Subject: [PATCH 5/8] Rewrite the Component PickWorkflow.vue into typescript --- .../lib/entity-workflow/{api.js => api.ts} | 8 +- .../ChillMainBundle/Resources/public/types.ts | 5 ++ .../EntityWorkflow/PickWorkflow.vue | 75 ++++++++----------- .../vuejs/AccompanyingCourseWorkEdit/App.vue | 2 +- .../components/AddEvaluation.vue | 2 +- .../components/FormEvaluation.vue | 2 +- 6 files changed, 42 insertions(+), 52 deletions(-) rename src/Bundle/ChillMainBundle/Resources/public/lib/entity-workflow/{api.js => api.ts} (52%) diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/entity-workflow/api.js b/src/Bundle/ChillMainBundle/Resources/public/lib/entity-workflow/api.ts similarity index 52% rename from src/Bundle/ChillMainBundle/Resources/public/lib/entity-workflow/api.js rename to src/Bundle/ChillMainBundle/Resources/public/lib/entity-workflow/api.ts index a89dd66f5..486421904 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/entity-workflow/api.js +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/entity-workflow/api.ts @@ -1,12 +1,8 @@ -const buildLinkCreate = function(workflowName, relatedEntityClass, relatedEntityId) { +export const buildLinkCreate = (workflowName: string, relatedEntityClass: string, relatedEntityId: number): string => { let params = new URLSearchParams(); params.set('entityClass', relatedEntityClass); - params.set('entityId', relatedEntityId); + params.set('entityId', relatedEntityId.toString(10)); params.set('workflow', workflowName); return `/fr/main/workflow/create?`+params.toString(); }; - -export { - buildLinkCreate, -}; diff --git a/src/Bundle/ChillMainBundle/Resources/public/types.ts b/src/Bundle/ChillMainBundle/Resources/public/types.ts index 2e33b8248..304ebd8e4 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/types.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/types.ts @@ -168,3 +168,8 @@ export interface NewsItemType { startDate: DateTime; endDate: DateTime | null; } + +export interface WorkflowAvailable { + name: string; + text: string; +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue index 9ed1ef6cc..e429adf31 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/PickWorkflow.vue @@ -1,63 +1,52 @@