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.
This commit is contained in:
Julien Fastré 2024-09-18 12:38:47 +02:00
parent b0e2e65885
commit 5906171041
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
8 changed files with 351 additions and 2 deletions

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\DocStoreBundle\Service\StoredObjectRestoreInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class StoredObjectRestoreVersionApiController
{
public function __construct(private Security $security, private StoredObjectRestoreInterface $storedObjectRestore, private EntityManagerInterface $entityManager, private SerializerInterface $serializer) {}
#[Route('/api/1.0/doc-store/stored-object/restore-from-version/{id}', methods: ['POST'])]
public function restoreStoredObjectVersion(StoredObjectVersion $storedObjectVersion): JsonResponse
{
if (!$this->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
);
}
}

View File

@ -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<int, StoredObjectVersion>
*/
#[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;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Psr\Log\LoggerInterface;
final readonly class StoredObjectRestore implements StoredObjectRestoreInterface
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager, private readonly LoggerInterface $logger) {}
public function restore(StoredObjectVersion $storedObjectVersion): StoredObjectVersion
{
$oldContent = $this->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;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
/**
* Restore an old version of the stored object as the current one.
*/
interface StoredObjectRestoreInterface
{
public function restore(StoredObjectVersion $storedObjectVersion): StoredObjectVersion;
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace App\Tests\Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\StoredObjectRestoreInterface;
use Chill\DocStoreBundle\Controller\StoredObjectRestoreVersionApiController;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectRestoreVersionApiControllerTest extends TestCase
{
public function testRestoreStoredObjectVersion(): 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);
$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);
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\StoredObjectRestore;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectRestoreTest extends TestCase
{
use ProphecyTrait;
public function testRestore(): void
{
$storedObject = new StoredObject();
$version = $storedObject->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());
}
}

View File

@ -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

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240918073234 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a relation between stored object version when a version is restored';
}
public function up(Schema $schema): void
{
$this->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');
}
}