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", diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectRestoreVersionApiController.php new file mode 100644 index 000000000..e3b4702f5 --- /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, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]), + json: true + ); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php new file mode 100644 index 000000000..819eb9f84 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Controller/StoredObjectVersionApiController.php @@ -0,0 +1,69 @@ +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', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]] + ), + 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/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/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 ee6b2bf98..f93e9c5b7 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/types.ts @@ -62,6 +62,17 @@ export interface StoredObjectStatusChange { type: string; } +export interface StoredObjectVersionWithPointInTime extends StoredObjectVersionPersisted { + "point-in-times": StoredObjectPointInTime[]; + "from-restored": StoredObjectVersionPersisted|null; +} + +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..9c7dac095 100644 --- a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DocumentActionButtonsGroup.vue @@ -14,7 +14,10 @@
  • - + +
  • +
  • +
  • @@ -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/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 @@ + + + + + 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 new file mode 100644 index 000000000..547416879 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton.vue @@ -0,0 +1,57 @@ + + + + + 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..c7be09a44 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonList.vue @@ -0,0 +1,68 @@ + + + + + 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..ec077540e --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue @@ -0,0 +1,113 @@ + + + + + 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..0112ec1d8 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonModal.vue @@ -0,0 +1,48 @@ + + + + diff --git a/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue new file mode 100644 index 000000000..0c2ba2996 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue @@ -0,0 +1,32 @@ + + + + + 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..b2865bc48 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/api.ts @@ -0,0 +1,12 @@ +import {StoredObject, StoredObjectVersionPersisted, 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) => b.version - a.version); +} + +export const restore_version = async (version: StoredObjectVersionPersisted): Promise => { + return await makeFetch("POST", `/api/1.0/doc-store/stored-object/restore-from-version/${version.id}`); +} 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..4b18989cb 100644 --- a/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php +++ b/src/Bundle/ChillDocStoreBundle/Serializer/Normalizer/StoredObjectVersionNormalizer.php @@ -12,6 +12,8 @@ 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; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -20,13 +22,17 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw { use NormalizerAwareTrait; + final public const WITH_POINT_IN_TIMES_CONTEXT = 'with-point-in-times'; + + final public const WITH_RESTORED_CONTEXT = 'with-restored'; + 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(), @@ -34,8 +40,18 @@ 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] ?? [], true)) { + $data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context); + } + + if (in_array(self::WITH_RESTORED_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) { + $data['from-restored'] = $this->normalizer->normalize($object->getCreatedFrom(), $format, [AbstractNormalizer::GROUPS => ['read']]); + } + + return $data; } public function supportsNormalization($data, ?string $format = null, array $context = []) 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/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/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())] + ); + } +} 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 28345e463..127430c70 100644 --- a/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml @@ -105,3 +105,50 @@ 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" + + /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'); + } +}