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'); + } +}