From 943a42cd3834626d9eb078a636db31f0ec2da583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 17 Sep 2024 11:55:55 +0200 Subject: [PATCH 1/8] Add StoredObjectVersionApiController and corresponding test Added a new class StoredObjectVersionApiController in ChillDocGeneratorBundle which lists versions of a specified stored object. Corresponding unit test has been added as well. Made modifications in `StoredObject.php` to make the versions selectable. Also updated the API specifications to include a new GET route for retrieving versions. --- .../StoredObjectVersionApiController.php | 64 +++++++++++++++ .../Entity/StoredObject.php | 7 +- .../StoredObjectVersionApiControllerTest.php | 77 +++++++++++++++++++ .../ChillDocStoreBundle/chill.api.specs.yaml | 25 ++++++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Controller/StoredObjectVersionApiControllerTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php new file mode 100644 index 000000000..4104ae67d --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php @@ -0,0 +1,64 @@ +security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) { + throw new AccessDeniedHttpException('not allowed to see this stored object'); + } + + $total = $storedObject->getVersions()->count(); + $paginator = $this->paginatorFactory->create($total); + + $criteria = Criteria::create(); + $criteria->orderBy(['id' => Order::Ascending]); + $criteria->setMaxResults($paginator->getItemsPerPage())->setFirstResult($paginator->getCurrentPageFirstItemNumber()); + $items = $storedObject->getVersions()->matching($criteria); + + return new JsonResponse( + $this->serializer->serialize(new Collection($items, $paginator), 'json', [AbstractNormalizer::GROUPS => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php index 6efc3fb9c..c6d9e362c 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObject.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\Mapping as ORM; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -89,10 +90,10 @@ class StoredObject implements Document, TrackCreationInterface private string $generationErrors = ''; /** - * @var Collection + * @var Collection&Selectable */ #[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)] - private Collection $versions; + private Collection&Selectable $versions; /** * @param StoredObject::STATUS_* $status @@ -256,7 +257,7 @@ class StoredObject implements Document, TrackCreationInterface return $this->template; } - public function getVersions(): Collection + public function getVersions(): Collection&Selectable { return $this->versions; } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/StoredObjectVersionApiControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/StoredObjectVersionApiControllerTest.php new file mode 100644 index 000000000..27154831d --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/StoredObjectVersionApiControllerTest.php @@ -0,0 +1,77 @@ +registerVersion(); + } + + $security = $this->prophesize(Security::class); + $security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject) + ->willReturn(true) + ->shouldBeCalledOnce(); + + $controller = $this->buildController($security->reveal()); + + $response = $controller->listVersions($storedObject); + $body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertEquals($response->getStatusCode(), 200); + self::assertIsArray($body); + self::assertArrayHasKey('results', $body); + self::assertCount(10, $body['results']); + } + + private function buildController(Security $security): StoredObjectVersionApiController + { + $paginator = $this->prophesize(Paginator::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + $paginator->getTotalItems()->willReturn(15); + $paginator->hasNextPage()->willReturn(false); + $paginator->hasPreviousPage()->willReturn(false); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(Argument::type('int'))->willReturn($paginator); + + $serializer = new Serializer([ + new StoredObjectVersionNormalizer(), new CollectionNormalizer(), + ], [new JsonEncoder()]); + + return new StoredObjectVersionApiController($paginatorFactory->reveal(), $serializer, $security); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml b/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml index 28345e463..fb1e71ba5 100644 --- a/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml @@ -105,3 +105,28 @@ paths: 404: description: "Not found" + /1.0/doc-store/stored-object/{uuid}/versions: + get: + tags: + - storedobject + summary: Get a signed route to post stored object + parameters: + - in: path + name: uuid + required: true + allowEmptyValue: false + description: The UUID of the storedObjeect + schema: + type: string + format: uuid + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: object + 403: + description: "Unauthorized" + 404: + description: "Not found" From 6a0e26ec314fbc560d9114ea5545ca980ec268a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 17 Sep 2024 13:23:30 +0200 Subject: [PATCH 2/8] Add point-in-time normalization to stored object versions Introduced a new normalizer for StoredObjectPointInTime and updated the StoredObjectVersionNormalizer to include point-in-time data when specified in the context. Added corresponding test cases to ensure the new normalization logic works correctly. --- .../StoredObjectVersionApiController.php | 7 +- .../StoredObjectPointInTimeNormalizer.php | 38 +++++++++++ .../StoredObjectVersionNormalizer.php | 11 +++- .../StoredObjectPointInTimeNormalizerTest.php | 64 +++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectPointInTimeNormalizer.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Serializer/Normalizer/StoredObjectPointInTimeNormalizerTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php index 4104ae67d..0ffbfc719 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php @@ -13,6 +13,7 @@ namespace Chill\DocStoreBundle\Controller; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; +use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer; use Chill\MainBundle\Pagination\PaginatorFactoryInterface; use Chill\MainBundle\Serializer\Model\Collection; use Doctrine\Common\Collections\Criteria; @@ -57,7 +58,11 @@ final readonly class StoredObjectVersionApiController $items = $storedObject->getVersions()->matching($criteria); return new JsonResponse( - $this->serializer->serialize(new Collection($items, $paginator), 'json', [AbstractNormalizer::GROUPS => ['read']]), + $this->serializer->serialize( + new Collection($items, $paginator), + 'json', + [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT]] + ), json: true ); } diff --git a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectPointInTimeNormalizer.php b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectPointInTimeNormalizer.php new file mode 100644 index 000000000..be8fc5f98 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectPointInTimeNormalizer.php @@ -0,0 +1,38 @@ + $object->getId(), + 'reason' => $object->getReason()->value, + 'byUser' => $this->normalizer->normalize($object->getByUser(), $format, [AbstractNormalizer::GROUPS => 'read']), + ]; + } + + public function supportsNormalization($data, ?string $format = null) + { + return $data instanceof StoredObjectPointInTime; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php index 15b997d20..d4a1adca2 100644 --- a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php +++ b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Serializer\Normalizer; use Chill\DocStoreBundle\Entity\StoredObjectVersion; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -20,13 +21,15 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw { use NormalizerAwareTrait; + final public const WITH_POINT_IN_TIMES_CONTEXT = 'with-point-in-times'; + public function normalize($object, ?string $format = null, array $context = []) { if (!$object instanceof StoredObjectVersion) { throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class); } - return [ + $data = [ 'id' => $object->getId(), 'filename' => $object->getFilename(), 'version' => $object->getVersion(), @@ -36,6 +39,12 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw 'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context), 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), ]; + + if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS])) { + $data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context); + } + + return $data; } public function supportsNormalization($data, ?string $format = null, array $context = []) diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Serializer/Normalizer/StoredObjectPointInTimeNormalizerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Serializer/Normalizer/StoredObjectPointInTimeNormalizerTest.php new file mode 100644 index 000000000..d53b20278 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Serializer/Normalizer/StoredObjectPointInTimeNormalizerTest.php @@ -0,0 +1,64 @@ +registerVersion(); + $storedObjectPointInTime = new StoredObjectPointInTime($version, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION, new User()); + + $normalizer = new StoredObjectPointInTimeNormalizer(); + $normalizer->setNormalizer($this->buildNormalizer()); + + $actual = $normalizer->normalize($storedObjectPointInTime, 'json', ['read']); + + self::assertIsArray($actual); + self::assertArrayHasKey('id', $actual); + self::assertArrayHasKey('byUser', $actual); + self::assertArrayHasKey('reason', $actual); + self::assertEquals(StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION->value, $actual['reason']); + } + + public function buildNormalizer(): NormalizerInterface + { + $userRender = $this->prophesize(UserRender::class); + $userRender->renderString(Argument::type(User::class), Argument::type('array'))->willReturn('username'); + + return new Serializer( + [new UserNormalizer($userRender->reveal(), new MockClock())] + ); + } +} From 48f727dcfd201c9af720893cffac14d16e2036dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 17 Sep 2024 17:04:28 +0200 Subject: [PATCH 3/8] Update vue version to ^3.5.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f0d6ca062..05750d5c9 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mime": "^4.0.0", "pdfjs-dist": "^4.3.136", "vis-network": "^9.1.0", - "vue": "^3.2.37", + "vue": "^3.5.6", "vue-i18n": "^9.1.6", "vue-multiselect": "3.0.0-alpha.2", "vue-toast-notification": "^3.1.2", From 5fa5a2349ef26474cf28a31f00c73fba0a44f24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 17 Sep 2024 17:05:15 +0200 Subject: [PATCH 4/8] Add FileIcon.vue and refactor DropFile.vue to use it Introduced `FileIcon.vue` to handle file type icons centrally. Refactored `DropFile.vue` to utilize the new `FileIcon` component, improving code clarity and maintainability. --- .../public/vuejs/DropFileWidget/DropFile.vue | 12 ++------- .../Resources/public/vuejs/FileIcon.vue | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/FileIcon.vue diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DropFileWidget/DropFile.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DropFileWidget/DropFile.vue index 464a689f8..99fc31524 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DropFileWidget/DropFile.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DropFileWidget/DropFile.vue @@ -3,6 +3,7 @@ import {StoredObject, StoredObjectVersionCreated} from "../../types"; import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader"; import {computed, ref, Ref} from "vue"; +import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue"; interface DropFileConfig { existingDoc?: StoredObject, @@ -111,16 +112,7 @@ const handleFile = async (file: File): Promise => {

- - - - - - - - - - +

{{ display_filename }}

diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/FileIcon.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/FileIcon.vue new file mode 100644 index 000000000..437d6bdc8 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/FileIcon.vue @@ -0,0 +1,25 @@ + + + + + From dd3f6fb0abdd9d3981f5769fadc8faad4bbc1a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 17 Sep 2024 17:08:00 +0200 Subject: [PATCH 5/8] Enhance StoredObjectVersion normalization Add UserNormalizer dependency and pass createdAt context for createdBy normalization, ensuring compatibility with nullable context groups. This improves the accuracy and completeness of the normalized data. --- .../Serializer/Normalizer/StoredObjectVersionNormalizer.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php index d4a1adca2..557780fa3 100644 --- a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php +++ b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Serializer\Normalizer; use Chill\DocStoreBundle\Entity\StoredObjectVersion; +use Chill\MainBundle\Serializer\Normalizer\UserNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; @@ -37,10 +38,10 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw 'keyInfos' => $object->getKeyInfos(), 'type' => $object->getType(), 'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context), - 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), + 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, [...$context, UserNormalizer::AT_DATE => $object->getCreatedAt()]), ]; - if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS])) { + if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) { $data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context); } From b0e2e658850f679802bb5550610f3a521d181095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 17 Sep 2024 17:08:27 +0200 Subject: [PATCH 6/8] Add document history button with modal viewer This commit introduces a History button to the DocumentActionButtonsGroup component to view document versions. It includes new components for the modal dialog and API integrations to fetch and display version histories. This feature allows users to view and restore previous versions of stored objects. --- .../Resources/public/types.ts | 10 ++++ .../vuejs/DocumentActionButtonsGroup.vue | 10 +++- .../StoredObjectButton/HistoryButton.vue | 53 +++++++++++++++++++ .../HistoryButton/HistoryButtonList.vue | 30 +++++++++++ .../HistoryButton/HistoryButtonListItem.vue | 35 ++++++++++++ .../HistoryButton/HistoryButtonModal.vue | 39 ++++++++++++++ .../StoredObjectButton/HistoryButton/api.ts | 8 +++ 7 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/api.ts diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts index ee6b2bf98..a6ce41512 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts @@ -62,6 +62,16 @@ export interface StoredObjectStatusChange { type: string; } +export interface StoredObjectVersionWithPointInTime extends StoredObjectVersionPersisted { + "point-in-times": StoredObjectPointInTime[]; +} + +export interface StoredObjectPointInTime { + id: number; + byUser: User | null; + reason: 'keep-before-conversion'|'keep-by-user'; +} + /** * Function executed by the WopiEditButton component. */ diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue index 66546f2a5..614dc7a78 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue @@ -16,6 +16,9 @@
  • +
  • + +
  • @@ -40,6 +43,7 @@ import { WopiEditButtonExecutableBeforeLeaveFunction } from "../types"; import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue"; +import HistoryButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton.vue"; interface DocumentActionButtonsGroupConfig { storedObject: StoredObject, @@ -126,7 +130,11 @@ const isConvertibleToPdf = computed(() => { && is_extension_viewable(props.storedObject.currentVersion.type) && props.storedObject.currentVersion.type !== 'application/pdf' && props.storedObject.currentVersion.persisted !== false; -}) +}); + +const isHistoryViewable = computed(() => { + return props.storedObject.status === 'ready'; +}); const checkForReady = function(): void { if ( diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue new file mode 100644 index 000000000..108256909 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue new file mode 100644 index 000000000..fccdb6a0d --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue new file mode 100644 index 000000000..c60d62302 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue new file mode 100644 index 000000000..13b994be2 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue @@ -0,0 +1,39 @@ + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/api.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/api.ts new file mode 100644 index 000000000..b48394826 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/api.ts @@ -0,0 +1,8 @@ +import {StoredObject, StoredObjectVersion, StoredObjectVersionWithPointInTime} from "../../../types"; +import {fetchResults, makeFetch} from "../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; + +export const get_versions = async (storedObject: StoredObject): Promise => { + const versions = await fetchResults(`/api/1.0/doc-store/stored-object/${storedObject.uuid}/versions`); + + return versions.sort((a: StoredObjectVersionWithPointInTime, b: StoredObjectVersionWithPointInTime) => a.version - b.version); +} From 59061710410ebb7028d0b0902f19a516215bfeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 18 Sep 2024 12:38:47 +0200 Subject: [PATCH 7/8] Add restoration functionality for stored object versions Introduce a service to restore stored object versions along with relevant tests and an API endpoint. This includes database migrations for version relationships, enhancing stored object version tracking. --- ...toredObjectRestoreVersionApiController.php | 47 ++++++++++++ .../Entity/StoredObjectVersion.php | 60 ++++++++++++++- .../Service/StoredObjectRestore.php | 38 ++++++++++ .../Service/StoredObjectRestoreInterface.php | 22 ++++++ ...dObjectRestoreVersionApiControllerTest.php | 74 +++++++++++++++++++ .../Tests/Service/StoredObjectRestoreTest.php | 53 +++++++++++++ .../ChillDocStoreBundle/chill.api.specs.yaml | 22 ++++++ .../migrations/Version20240918073234.php | 37 ++++++++++ 8 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php create mode 100644 src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestore.php create mode 100644 src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestoreInterface.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Controller/StoredObjectRestoreVersionApiControllerTest.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectRestoreTest.php create mode 100644 src/Bundle/ChillDocStoreBundle/migrations/Version20240918073234.php diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php new file mode 100644 index 000000000..7e9c12943 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php @@ -0,0 +1,47 @@ +security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObjectVersion->getStoredObject())) { + throw new AccessDeniedHttpException('not allowed to edit the stored object'); + } + + $newVersion = $this->storedObjectRestore->restore($storedObjectVersion); + + $this->entityManager->persist($newVersion); + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($newVersion, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT]]), + json: true + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php index e034cb378..72532def7 100644 --- a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectVersion.php @@ -48,6 +48,25 @@ class StoredObjectVersion implements TrackCreationInterface #[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection&Selectable $pointInTimes; + /** + * Previous storedObjectVersion, from which the current stored object version is created. + * + * If null, the current stored object version is generated by other means. + * + * Those version may be associated with the same storedObject, or not. In this last case, that means that + * the stored object's current version is created from another stored object version. + */ + #[ORM\ManyToOne(targetEntity: StoredObjectVersion::class)] + private ?StoredObjectVersion $createdFrom = null; + + /** + * List of stored object versions created from the current version. + * + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'createdFrom', targetEntity: StoredObjectVersion::class)] + private Collection $children; + public function __construct( /** * The stored object associated with this version. @@ -87,6 +106,7 @@ class StoredObjectVersion implements TrackCreationInterface ) { $this->filename = $filename ?? self::generateFilename($this); $this->pointInTimes = new ArrayCollection(); + $this->children = new ArrayCollection(); } public static function generateFilename(StoredObjectVersion $storedObjectVersion): string @@ -149,8 +169,6 @@ class StoredObjectVersion implements TrackCreationInterface } /** - * @return $this - * * @internal use @see{StoredObjectPointInTime} constructor instead */ public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self @@ -170,4 +188,42 @@ class StoredObjectVersion implements TrackCreationInterface return $this; } + + public function getCreatedFrom(): ?StoredObjectVersion + { + return $this->createdFrom; + } + + public function setCreatedFrom(?StoredObjectVersion $createdFrom): StoredObjectVersion + { + if (null === $createdFrom && null !== $this->createdFrom) { + $this->createdFrom->removeChild($this); + } + + $createdFrom?->addChild($this); + + $this->createdFrom = $createdFrom; + + return $this; + } + + public function addChild(StoredObjectVersion $child): self + { + if (!$this->children->contains($child)) { + $this->children->add($child); + } + + return $this; + } + + public function removeChild(StoredObjectVersion $child): self + { + $result = $this->children->removeElement($child); + + if (false === $result) { + throw new \UnexpectedValueException('the child is not associated with the current stored object version'); + } + + return $this; + } } diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestore.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestore.php new file mode 100644 index 000000000..3c6469185 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestore.php @@ -0,0 +1,38 @@ +storedObjectManager->read($storedObjectVersion); + + $newVersion = $this->storedObjectManager->write($storedObjectVersion->getStoredObject(), $oldContent, $storedObjectVersion->getType()); + + $newVersion->setCreatedFrom($storedObjectVersion); + + $this->logger->info('[StoredObjectRestore] Restore stored object version', [ + 'stored_object_uuid' => $storedObjectVersion->getStoredObject()->getUuid(), + 'old_version_id' => $storedObjectVersion->getId(), + 'old_version_version' => $storedObjectVersion->getVersion(), + 'new_version_id' => $newVersion->getVersion(), + ]); + + return $newVersion; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestoreInterface.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestoreInterface.php new file mode 100644 index 000000000..516ff4bf6 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectRestoreInterface.php @@ -0,0 +1,22 @@ +createMock(Security::class); + $storedObjectRestore = $this->createMock(StoredObjectRestoreInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $serializer = $this->createMock(SerializerInterface::class); + $storedObjectVersion = $this->createMock(StoredObjectVersion::class); + $controller = new StoredObjectRestoreVersionApiController($security, $storedObjectRestore, $entityManager, $serializer); + + $security->expects($this->once()) + ->method('isGranted') + ->willReturn(true); + $storedObjectRestore->expects($this->once()) + ->method('restore') + ->willReturn($storedObjectVersion); + $entityManager->expects($this->once()) + ->method('persist'); + $entityManager->expects($this->once()) + ->method('flush'); + + $serializer->expects($this->once()) + ->method('serialize') + ->willReturn('test'); + + $response = $controller->restoreStoredObjectVersion($storedObjectVersion); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('test', $response->getContent()); + } + + public function testRestoreStoredObjectVersionAccessDenied(): void + { + $security = $this->createMock(Security::class); + $storedObjectRestore = $this->createMock(StoredObjectRestoreInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $serializer = $this->createMock(SerializerInterface::class); + $storedObjectVersion = $this->createMock(StoredObjectVersion::class); + $controller = new StoredObjectRestoreVersionApiController($security, $storedObjectRestore, $entityManager, $serializer); + + self::expectException(\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException::class); + $security->expects($this->once()) + ->method('isGranted') + ->willReturn(false); + $controller->restoreStoredObjectVersion($storedObjectVersion); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectRestoreTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectRestoreTest.php new file mode 100644 index 000000000..2366f6e04 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectRestoreTest.php @@ -0,0 +1,53 @@ +registerVersion(type: 'application/test'); + + $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); + $storedObjectManager->read($version)->willReturn('1234')->shouldBeCalledOnce(); + $storedObjectManager->write($storedObject, '1234', 'application/test')->shouldBeCalledOnce() + ->will(function ($args) { + /** @var StoredObject $object */ + $object = $args[0]; + + return $object->registerVersion(); + }) + ; + + $restore = new StoredObjectRestore($storedObjectManager->reveal(), new NullLogger()); + + $newVersion = $restore->restore($version); + + self::assertNotSame($version, $newVersion); + self::assertSame($version, $newVersion->getCreatedFrom()); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml b/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml index fb1e71ba5..127430c70 100644 --- a/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml @@ -130,3 +130,25 @@ paths: description: "Unauthorized" 404: description: "Not found" + + /1.0/doc-store/stored-object/restore-from-version/{id}: + post: + tags: + - storedobject + summary: Restore an old version of a stored object + parameters: + - in: path + name: id + required: true + allowEmptyValue: false + description: The id of the stored object version + schema: + type: integer + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: object + diff --git a/src/Bundle/ChillDocStoreBundle/migrations/Version20240918073234.php b/src/Bundle/ChillDocStoreBundle/migrations/Version20240918073234.php new file mode 100644 index 000000000..1189f8f08 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/migrations/Version20240918073234.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE chill_doc.stored_object_version ADD createdFrom_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ADD CONSTRAINT FK_C1D553024DEC38BB FOREIGN KEY (createdFrom_id) REFERENCES chill_doc.stored_object_version (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_C1D553024DEC38BB ON chill_doc.stored_object_version (createdFrom_id)'); + $this->addSql('ALTER INDEX chill_doc.idx_c1d55302232d562b RENAME TO IDX_C1D553024B136083'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_doc.stored_object_version DROP createdFrom_id'); + $this->addSql('ALTER INDEX chill_doc.idx_c1d553024b136083 RENAME TO idx_c1d55302232d562b'); + } +} From 47f575de92d88a23aae8dac7eab687a6b4381ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 18 Sep 2024 23:54:06 +0200 Subject: [PATCH 8/8] Enhance version restoration and download features Introduce a version restoration button and logic to track restored versions throughout the UI. Update download buttons to display action strings conditionally and implement toast notifications for version restoration. --- ...toredObjectRestoreVersionApiController.php | 2 +- .../StoredObjectVersionApiController.php | 2 +- .../document_action_buttons_group/index.ts | 3 +- .../Resources/public/types.ts | 1 + .../vuejs/DocumentActionButtonsGroup.vue | 2 +- .../StoredObjectButton/DownloadButton.vue | 11 +- .../StoredObjectButton/HistoryButton.vue | 6 +- .../HistoryButton/HistoryButtonList.vue | 48 ++++++++- .../HistoryButton/HistoryButtonListItem.vue | 100 ++++++++++++++++-- .../HistoryButton/HistoryButtonModal.vue | 13 ++- .../HistoryButton/RestoreVersionButton.vue | 32 ++++++ .../StoredObjectButton/HistoryButton/api.ts | 8 +- .../StoredObjectVersionNormalizer.php | 6 ++ 13 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php index 7e9c12943..e3b4702f5 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php @@ -40,7 +40,7 @@ final readonly class StoredObjectRestoreVersionApiController $this->entityManager->flush(); return new JsonResponse( - $this->serializer->serialize($newVersion, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT]]), + $this->serializer->serialize($newVersion, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]), json: true ); } diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php index 0ffbfc719..819eb9f84 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php @@ -61,7 +61,7 @@ final readonly class StoredObjectVersionApiController $this->serializer->serialize( new Collection($items, $paginator), 'json', - [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT]] + [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]] ), json: true ); diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts index 77eb8c2c9..f9cf13a97 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/module/document_action_buttons_group/index.ts @@ -3,6 +3,7 @@ import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.v import {createApp} from "vue"; import {StoredObject, StoredObjectStatusChange} from "../../types"; import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers"; +import ToastPlugin from "vue-toast-notification"; const i18n = _createI18n({}); @@ -48,6 +49,6 @@ window.addEventListener('DOMContentLoaded', function (e) { } }); - app.use(i18n).mount(el); + app.use(i18n).use(ToastPlugin).mount(el); }) }); diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts index a6ce41512..f93e9c5b7 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts @@ -64,6 +64,7 @@ export interface StoredObjectStatusChange { export interface StoredObjectVersionWithPointInTime extends StoredObjectVersionPersisted { "point-in-times": StoredObjectPointInTime[]; + "from-restored": StoredObjectVersionPersisted|null; } export interface StoredObjectPointInTime { diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue index 614dc7a78..9c7dac095 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue @@ -14,7 +14,7 @@
  • - +
  • diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DownloadButton.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DownloadButton.vue index 92f82dc9d..d1efabcee 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DownloadButton.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/DownloadButton.vue @@ -1,11 +1,11 @@ @@ -20,6 +20,7 @@ interface DownloadButtonConfig { atVersion: StoredObjectVersion, classes: { [k: string]: boolean }, filename?: string, + displayActionStringInButton: boolean, } interface DownloadButtonState { @@ -28,7 +29,7 @@ interface DownloadButtonState { href_url: string, } -const props = defineProps(); +const props = withDefaults(defineProps(), {displayActionStringInButton: true}); const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"}); const open_button = ref(null); diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue index 108256909..547416879 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue @@ -36,11 +36,15 @@ const download_version_and_open_modal = async function (): Promise { } } +const onRestoreVersion = ({newVersion}: {newVersion: StoredObjectVersionWithPointInTime}) => { + state.versions.unshift(newVersion); +} +