From f5ba5d574b905ae66755d5c6f7dc733111cb5e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 10 Sep 2024 10:44:45 +0200 Subject: [PATCH 1/6] Add WopiConverter service and update Collabora integration tests Introduce the WopiConverter service to handle document-to-PDF conversion using Collabora Online. Extend and update related tests in WopiConvertToPdfTest and ConvertControllerTest for better coverage and reliability. Enhance the GitLab CI configuration to exclude new test category "collabora-integration". --- .env | 2 +- .env.test | 2 + .gitlab-ci.yml | 2 +- .../src/Controller/ConvertController.php | 64 +++------------- .../src/Service/WopiConverter.php | 69 ++++++++++++++++++ .../Controller/ConvertControllerTest.php | 56 ++++++-------- .../tests/Service/WopiConvertToPdfTest.php | 63 ++++++++++++++++ .../tests/Service/fixtures/test-document.odt | Bin 0 -> 8918 bytes tests/app/config/packages/wopi.yaml | 2 + 9 files changed, 171 insertions(+), 89 deletions(-) create mode 100644 src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php create mode 100644 src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php create mode 100644 src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt create mode 100644 tests/app/config/packages/wopi.yaml diff --git a/.env b/.env index 1714966d4..b2eecb78f 100644 --- a/.env +++ b/.env @@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$' ###< symfony/framework-bundle ### ## Wopi server for editing documents online -WOPI_SERVER=http://collabora:9980 +EDITOR_SERVER=http://collabora:9980 # must be manually set in .env.local # ADMIN_PASSWORD= diff --git a/.env.test b/.env.test index f84920e54..c78a1bc63 100644 --- a/.env.test +++ b/.env.test @@ -41,3 +41,5 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/test?serverVersion=14&chars ASYNC_UPLOAD_TEMP_URL_KEY= ASYNC_UPLOAD_TEMP_URL_BASE_PATH= ASYNC_UPLOAD_TEMP_URL_CONTAINER= + +EDITOR_SERVER=https://localhost:9980 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index acd66a42e..c1fdebf43 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,7 +122,7 @@ unit_tests: - php tests/console chill:db:sync-views --env=test - php -d memory_limit=2G tests/console cache:clear --env=test - php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test - - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration + - php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive,openstack-integration,collabora-integration artifacts: expire_in: 1 day paths: diff --git a/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php b/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php index 73207e13a..86a624e4b 100644 --- a/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php +++ b/src/Bundle/ChillWopiBundle/src/Controller/ConvertController.php @@ -14,84 +14,44 @@ namespace Chill\WopiBundle\Controller; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManager; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use Chill\MainBundle\Entity\User; +use Chill\WopiBundle\Service\WopiConverter; use Psr\Log\LoggerInterface; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; -use Symfony\Component\Mime\Part\DataPart; -use Symfony\Component\Mime\Part\Multipart\FormDataPart; use Symfony\Component\Security\Core\Security; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; class ConvertController { private const LOG_PREFIX = '[convert] '; - private readonly string $collaboraDomain; - /** * @param StoredObjectManager $storedObjectManager */ public function __construct( - private readonly HttpClientInterface $httpClient, - private readonly RequestStack $requestStack, private readonly Security $security, private readonly StoredObjectManagerInterface $storedObjectManager, + private readonly WopiConverter $wopiConverter, private readonly LoggerInterface $logger, - ParameterBagInterface $parameters, - ) { - $this->collaboraDomain = $parameters->get('wopi')['server']; - } + ) {} - public function __invoke(StoredObject $storedObject): Response + public function __invoke(StoredObject $storedObject, Request $request): Response { - if (!$this->security->getUser() instanceof User) { + if (!($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'))) { throw new AccessDeniedHttpException('User must be authenticated'); } $content = $this->storedObjectManager->read($storedObject); - $query = []; - if (null !== $request = $this->requestStack->getCurrentRequest()) { - $query['lang'] = $request->getLocale(); - } + $lang = $request->getLocale(); try { - $url = sprintf('%s/cool/convert-to/pdf', $this->collaboraDomain); - $form = new FormDataPart([ - 'data' => new DataPart($content, $storedObject->getUuid()->toString(), $storedObject->getType()), - ]); - $response = $this->httpClient->request('POST', $url, [ - 'headers' => $form->getPreparedHeaders()->toArray(), - 'query' => $query, - 'body' => $form->bodyToString(), - 'timeout' => 10, - ]); - - return new Response($response->getContent(), Response::HTTP_OK, [ + return new Response($this->wopiConverter->convert($lang, $content, $storedObject->getType()), Response::HTTP_OK, [ 'Content-Type' => 'application/pdf', ]); - } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $exception) { - return $this->onConversionFailed($url, $exception->getResponse()); + } catch (\RuntimeException $exception) { + $this->logger->alert(self::LOG_PREFIX.'Could not convert document', ['message' => $exception->getMessage(), 'exception', $exception->getTraceAsString()]); + + return new Response('convert server not available', Response::HTTP_SERVICE_UNAVAILABLE); } } - - private function onConversionFailed(string $url, ResponseInterface $response): JsonResponse - { - $this->logger->error(self::LOG_PREFIX.' could not convert document', [ - 'response_status' => $response->getStatusCode(), - 'message' => $response->getContent(false), - 'server' => $this->collaboraDomain, - 'url' => $url, - ]); - - return new JsonResponse(['message' => 'conversion failed : '.$response->getContent(false)], Response::HTTP_SERVICE_UNAVAILABLE); - } } diff --git a/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php b/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php new file mode 100644 index 000000000..ded9b3188 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php @@ -0,0 +1,69 @@ +collaboraDomain = $parameters->get('wopi')['server']; + } + + public function convert(string $lang, string $content, string $contentType, $convertTo = 'pdf'): string + { + try { + $url = sprintf('%s/cool/convert-to/%s', $this->collaboraDomain, $convertTo); + + $form = new FormDataPart([ + 'data' => new DataPart($content, uniqid('temp-file-'), contentType: $contentType), + ]); + $response = $this->httpClient->request('POST', $url, [ + 'headers' => $form->getPreparedHeaders()->toArray(), + 'query' => ['lang' => $lang], + 'body' => $form->bodyToString(), + 'timeout' => 10, + ]); + + if (200 === $response->getStatusCode()) { + $this->logger->info(self::LOG_PREFIX.'document converted successfully', ['size' => strlen($content)]); + } + + return $response->getContent(); + } catch (ClientExceptionInterface $e) { + throw new \LogicException('no correct request to collabora online', previous: $e); + } catch (RedirectionExceptionInterface $e) { + throw new \RuntimeException('no redirection expected', previous: $e); + } catch (ServerExceptionInterface|TransportExceptionInterface $e) { + throw new \RuntimeException('error while converting document', previous: $e); + } + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php index 0a928bcd9..9a529df6b 100644 --- a/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php +++ b/src/Bundle/ChillWopiBundle/tests/Controller/ConvertControllerTest.php @@ -13,16 +13,12 @@ namespace Chill\WopiBundle\Tests\Controller; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; -use Chill\MainBundle\Entity\User; use Chill\WopiBundle\Controller\ConvertController; +use Chill\WopiBundle\Service\WopiConverter; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\NullLogger; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Security; /** @@ -39,28 +35,27 @@ final class ConvertControllerTest extends TestCase $storedObject = new StoredObject(); $storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text'); - $httpClient = new MockHttpClient([ - new MockResponse('not authorized', ['http_code' => 401]), - ], 'http://collabora:9980'); - $security = $this->prophesize(Security::class); - $security->getUser()->willReturn(new User()); + $security->isGranted('ROLE_USER')->willReturn(true); $storeManager = $this->prophesize(StoredObjectManagerInterface::class); $storeManager->read($storedObject)->willReturn('content'); - $parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]); + $wopiConverter = $this->prophesize(WopiConverter::class); + $wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text') + ->willThrow(new \RuntimeException()); - $convert = new ConvertController( - $httpClient, - $this->makeRequestStack(), + $controller = new ConvertController( $security->reveal(), $storeManager->reveal(), + $wopiConverter->reveal(), new NullLogger(), - $parameterBag ); - $response = $convert($storedObject); + $request = new Request(); + $request->setLocale('fr'); + + $response = $controller($storedObject, $request); $this->assertNotEquals(200, $response->getStatusCode()); } @@ -70,38 +65,29 @@ final class ConvertControllerTest extends TestCase $storedObject = new StoredObject(); $storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text'); - $httpClient = new MockHttpClient([ - new MockResponse('1234', ['http_code' => 200]), - ], 'http://collabora:9980'); - $security = $this->prophesize(Security::class); - $security->getUser()->willReturn(new User()); + $security->isGranted('ROLE_USER')->willReturn(true); $storeManager = $this->prophesize(StoredObjectManagerInterface::class); $storeManager->read($storedObject)->willReturn('content'); - $parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]); + $wopiConverter = $this->prophesize(WopiConverter::class); + $wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text') + ->willReturn('1234'); - $convert = new ConvertController( - $httpClient, - $this->makeRequestStack(), + $controller = new ConvertController( $security->reveal(), $storeManager->reveal(), + $wopiConverter->reveal(), new NullLogger(), - $parameterBag ); - $response = $convert($storedObject); + $request = new Request(); + $request->setLocale('fr'); + + $response = $controller($storedObject, $request); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('1234', $response->getContent()); } - - private function makeRequestStack(): RequestStack - { - $requestStack = new RequestStack(); - $requestStack->push(new Request()); - - return $requestStack; - } } diff --git a/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php b/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php new file mode 100644 index 000000000..317932ea5 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/tests/Service/WopiConvertToPdfTest.php @@ -0,0 +1,63 @@ + ['server' => $_ENV['EDITOR_SERVER']], + ]); + + $converter = new WopiConverter($client, new NullLogger(), $parameters); + + $actual = $converter->convert('fr', $content, 'application/vnd.oasis.opendocument.text'); + + self::assertIsString($actual); + } + + public function testConvertToPdfWithMock(): void + { + $httpClient = new MockHttpClient([ + new MockResponse('1234', ['http_code' => 200]), + ], 'http://collabora:9980'); + $parameters = new ParameterBag([ + 'wopi' => ['server' => 'http://collabora:9980'], + ]); + + $converter = new WopiConverter($httpClient, new NullLogger(), $parameters); + + $actual = $converter->convert('fr', 'content-string', 'application/vnd.oasis.opendocument.text'); + + self::assertEquals('1234', $actual); + } +} diff --git a/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt b/src/Bundle/ChillWopiBundle/tests/Service/fixtures/test-document.odt new file mode 100644 index 0000000000000000000000000000000000000000..b6f6444082151390d1fc8e52ea568c70c745d22b GIT binary patch literal 8918 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 Date: Tue, 10 Sep 2024 12:05:23 +0200 Subject: [PATCH 2/6] Add StoredObjectPointInTime entity and related functionality Implemented a new StoredObjectPointInTime entity to manage snapshots of stored objects. This includes related migrations, enum for reasons, repository, and integration with StoredObjectVersion. --- .../Entity/StoredObjectPointInTime.php | 67 +++++++++++++++++++ .../StoredObjectPointInTimeReasonEnum.php | 18 +++++ .../Entity/StoredObjectVersion.php | 46 +++++++++++++ .../StoredObjectPointInTimeRepository.php | 27 ++++++++ .../migrations/Version20240910093735.php | 49 ++++++++++++++ 5 files changed, 207 insertions(+) create mode 100644 src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php create mode 100644 src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php create mode 100644 src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php create mode 100644 src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php new file mode 100644 index 000000000..bff5c60c1 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTime.php @@ -0,0 +1,67 @@ +objectVersion->addPointInTime($this); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getByUser(): ?User + { + return $this->byUser; + } + + public function getObjectVersion(): StoredObjectVersion + { + return $this->objectVersion; + } + + public function getReason(): StoredObjectPointInTimeReasonEnum + { + return $this->reason; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php new file mode 100644 index 000000000..9f03c7279 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Entity/StoredObjectPointInTimeReasonEnum.php @@ -0,0 +1,18 @@ + ''])] private string $filename = ''; + /** + * @var Collection&Selectable + */ + #[ORM\OneToMany(mappedBy: 'objectVersion', targetEntity: StoredObjectPointInTime::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection&Selectable $pointInTimes; + public function __construct( /** * The stored object associated with this version. @@ -77,6 +86,7 @@ class StoredObjectVersion implements TrackCreationInterface ?string $filename = null, ) { $this->filename = $filename ?? self::generateFilename($this); + $this->pointInTimes = new ArrayCollection(); } public static function generateFilename(StoredObjectVersion $storedObjectVersion): string @@ -124,4 +134,40 @@ class StoredObjectVersion implements TrackCreationInterface { return $this->version; } + + /** + * @return Collection&Selectable + */ + public function getPointInTimes(): Selectable&Collection + { + return $this->pointInTimes; + } + + public function hasPointInTimes(): bool + { + return $this->pointInTimes->count() > 0; + } + + /** + * @return $this + * + * @internal use @see{StoredObjectPointInTime} constructor instead + */ + public function addPointInTime(StoredObjectPointInTime $storedObjectPointInTime): self + { + if (!$this->pointInTimes->contains($storedObjectPointInTime)) { + $this->pointInTimes->add($storedObjectPointInTime); + } + + return $this; + } + + public function removePointInTime(StoredObjectPointInTime $storedObjectPointInTime): self + { + if ($this->pointInTimes->contains($storedObjectPointInTime)) { + $this->pointInTimes->removeElement($storedObjectPointInTime); + } + + return $this; + } } diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php new file mode 100644 index 000000000..c5c923ac9 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectPointInTimeRepository.php @@ -0,0 +1,27 @@ + + */ +class StoredObjectPointInTimeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, StoredObjectPointInTime::class); + } +} diff --git a/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php b/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php new file mode 100644 index 000000000..21906b168 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/migrations/Version20240910093735.php @@ -0,0 +1,49 @@ +addSql('CREATE SEQUENCE chill_doc.stored_object_point_in_time_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_doc.stored_object_point_in_time (id INT NOT NULL, stored_object_version_id INT NOT NULL, reason TEXT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, byUser_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_CC83C7B81D0AB8B9 ON chill_doc.stored_object_point_in_time (stored_object_version_id)'); + $this->addSql('CREATE INDEX IDX_CC83C7B8D23C0240 ON chill_doc.stored_object_point_in_time (byUser_id)'); + $this->addSql('CREATE INDEX IDX_CC83C7B83174800F ON chill_doc.stored_object_point_in_time (createdBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_doc.stored_object_point_in_time.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B81D0AB8B9 FOREIGN KEY (stored_object_version_id) REFERENCES chill_doc.stored_object_version (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B8D23C0240 FOREIGN KEY (byUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time ADD CONSTRAINT FK_CC83C7B83174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_doc.stored_object ALTER prefix SET DEFAULT \'\''); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ALTER filename SET DEFAULT \'\''); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_doc.stored_object_point_in_time_id_seq CASCADE'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B81D0AB8B9'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B8D23C0240'); + $this->addSql('ALTER TABLE chill_doc.stored_object_point_in_time DROP CONSTRAINT FK_CC83C7B83174800F'); + $this->addSql('DROP TABLE chill_doc.stored_object_point_in_time'); + $this->addSql('ALTER TABLE chill_doc.stored_object ALTER prefix DROP DEFAULT'); + $this->addSql('ALTER TABLE chill_doc.stored_object_version ALTER filename DROP DEFAULT'); + } +} From 669b967899527e341a8580164695e66544fd1733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 10 Sep 2024 12:06:09 +0200 Subject: [PATCH 3/6] Enhance object version removal to exclude point-in-time versions Add a check to exclude versions associated with points in time before deleting old object versions. This ensures that such versions are not mistakenly removed, providing greater data integrity. Updated tests and repository methods accordingly. --- .../Repository/StoredObjectVersionRepository.php | 4 +++- .../StoredObjectCleaner/RemoveOldVersionCronJob.php | 2 +- .../RemoveOldVersionMessageHandler.php | 8 +++++++- .../Repository/StoredObjectVersionRepositoryTest.php | 2 +- .../StoredObjectCleaner/RemoveOldVersionCronJobTest.php | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php index 1ab9b9edd..60ea07420 100644 --- a/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php +++ b/src/Bundle/ChillDocStoreBundle/Repository/StoredObjectVersionRepository.php @@ -62,7 +62,7 @@ class StoredObjectVersionRepository implements ObjectRepository * * @return iterable returns an iterable with the IDs of the versions */ - public function findIdsByVersionsOlderThanDateAndNotLastVersion(\DateTimeImmutable $beforeDate): iterable + public function findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(\DateTimeImmutable $beforeDate): iterable { $results = $this->connection->executeQuery( self::QUERY_FIND_IDS_BY_VERSIONS_OLDER_THAN_DATE_AND_NOT_LAST_VERSION, @@ -83,6 +83,8 @@ class StoredObjectVersionRepository implements ObjectRepository sov.createdat < ?::timestamp AND sov.version < (SELECT MAX(sub_sov.version) FROM chill_doc.stored_object_version sub_sov WHERE sub_sov.stored_object_id = sov.stored_object_id) + AND + NOT EXISTS (SELECT 1 FROM chill_doc.stored_object_point_in_time sub_poi WHERE sub_poi.stored_object_version_id = sov.id) SQL; public function getClassName(): string diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php index d190a4e45..d2494266b 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionCronJob.php @@ -50,7 +50,7 @@ final readonly class RemoveOldVersionCronJob implements CronJobInterface $deleteBeforeDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL)); $maxDeleted = $lastExecutionData[self::LAST_DELETED_KEY] ?? 0; - foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersion($deleteBeforeDate) as $id) { + foreach ($this->storedObjectVersionRepository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime($deleteBeforeDate) as $id) { $this->messageBus->dispatch(new RemoveOldVersionMessage($id)); $maxDeleted = max($maxDeleted, $id); } diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php index 69c9f283d..ea3e37be7 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectCleaner/RemoveOldVersionMessageHandler.php @@ -18,6 +18,7 @@ use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; use Symfony\Component\Messenger\Handler\MessageHandlerInterface; /** @@ -49,13 +50,18 @@ final readonly class RemoveOldVersionMessageHandler implements MessageHandlerInt $this->logger->info(self::LOG_PREFIX.'Received one message', ['storedObjectVersionId' => $message->storedObjectVersionId]); $storedObjectVersion = $this->storedObjectVersionRepository->find($message->storedObjectVersionId); - $storedObject = $storedObjectVersion->getStoredObject(); if (null === $storedObjectVersion) { $this->logger->error(self::LOG_PREFIX.'StoredObjectVersion not found in database', ['storedObjectVersionId' => $message->storedObjectVersionId]); throw new \RuntimeException('StoredObjectVersion not found with id '.$message->storedObjectVersionId); } + if ($storedObjectVersion->hasPointInTimes()) { + throw new UnrecoverableMessageHandlingException('the stored object version is now associated with a point in time'); + } + + $storedObject = $storedObjectVersion->getStoredObject(); + $this->storedObjectManager->delete($storedObjectVersion); // to ensure an immediate deletion $this->entityManager->remove($storedObjectVersion); diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php index ace122bea..4b134a1fb 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/Repository/StoredObjectVersionRepositoryTest.php @@ -35,7 +35,7 @@ class StoredObjectVersionRepositoryTest extends KernelTestCase $repository = new StoredObjectVersionRepository($this->entityManager); // get old version, to get a chance to get one - $actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersion(new \DateTimeImmutable('1970-01-01')); + $actual = $repository->findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime(new \DateTimeImmutable('1970-01-01')); self::assertIsIterable($actual); self::assertContainsOnly('int', $actual); diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php index 937253c81..df75eea93 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectCleaner/RemoveOldVersionCronJobTest.php @@ -46,7 +46,7 @@ class RemoveOldVersionCronJobTest extends KernelTestCase $clock = new MockClock(new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('+00:00'))); $repository = $this->createMock(StoredObjectVersionRepository::class); $repository->expects($this->once()) - ->method('findIdsByVersionsOlderThanDateAndNotLastVersion') + ->method('findIdsByVersionsOlderThanDateAndNotLastVersionAndNotPointInTime') ->with(new \DateTime('2023-10-03 00:00:00', new \DateTimeZone('+00:00'))) ->willReturnCallback(function ($arg) { yield 1; From 1ddd283f26bf0ab545a28b7a41becebc030efc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 10 Sep 2024 14:19:14 +0200 Subject: [PATCH 4/6] Add signer type differentiation for workflows Added a method to determine if the signer is a 'person' or 'user'. Updated the signature template to handle both types accordingly, ensuring the correct entity type is displayed in workflow signatures. --- .../Workflow/EntityWorkflowStepSignature.php | 12 +++++++++++ .../views/Workflow/_signature.html.twig | 20 +++++++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php index 527ede0ef..7f6c5cdda 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStepSignature.php @@ -140,4 +140,16 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate { return EntityWorkflowSignatureStateEnum::SIGNED == $this->getState(); } + + /** + * @return 'person'|'user' + */ + public function getSignerKind(): string + { + if ($this->personSigner instanceof Person) { + return 'person'; + } + + return 'user'; + } } diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig index 68b6f4274..50172089c 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/_signature.html.twig @@ -4,12 +4,20 @@ {% for s in signatures %}
- {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { - action: 'show', displayBadge: true, - targetEntity: { name: 'person', id: s.signer.id }, - buttonText: s.signer|chill_entity_render_string, - isDead: s.signer.deathDate is not null - } %} + {% if s.signerKind == 'person' %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'person', id: s.signer.id }, + buttonText: s.signer|chill_entity_render_string, + isDead: s.signer.deathDate is not null + } %} + {% else %} + {% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with { + action: 'show', displayBadge: true, + targetEntity: { name: 'user', id: s.signer.id }, + buttonText: s.signer|chill_entity_render_string, + } %} + {% endif %}
{% if s.isSigned %} From a60ea0e0661674ee56bde9ce823a3d5b8985e380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 10 Sep 2024 14:28:47 +0200 Subject: [PATCH 5/6] Add StoredObjectToPdfConverter service and unit tests Introduced the StoredObjectToPdfConverter service to handle conversion of stored objects to PDF format. Added unit tests to ensure proper functionality, including versioning and exception handling. --- .../Service/StoredObjectToPdfConverter.php | 75 +++++++++++++++++++ .../StoredObjectToPdfConverterTest.php | 61 +++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php new file mode 100644 index 000000000..abfcca4cc --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectToPdfConverter.php @@ -0,0 +1,75 @@ +mimeTypes->getMimeTypes($convertTo)[0] ?? null; + + if (null === $newMimeType) { + throw new \UnexpectedValueException(sprintf('could not find a preferred mime type for conversion to %s', $convertTo)); + } + + $currentVersion = $storedObject->getCurrentVersion(); + + if ($currentVersion->getType() === $newMimeType) { + throw new \UnexpectedValueException('Already at the same mime type'); + } + + $content = $this->storedObjectManager->read($currentVersion); + + try { + $converted = $this->wopiConverter->convert($lang, $content, $newMimeType, $convertTo); + } catch (\RuntimeException $e) { + throw new \RuntimeException('could not store a new version for document', previous: $e); + } + + $pointInTime = new StoredObjectPointInTime($currentVersion, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + $version = $this->storedObjectManager->write($storedObject, $converted, $newMimeType); + + return [$pointInTime, $version]; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php new file mode 100644 index 000000000..da3ed9210 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectToPdfConverterTest.php @@ -0,0 +1,61 @@ +registerVersion(type: 'text/html'); + + $storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class); + $storedObjectManager->read($currentVersion)->willReturn('1234'); + $storedObjectManager->write($storedObject, '5678', 'application/pdf')->shouldBeCalled() + ->will(function ($args) { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; + + return $storedObject->registerVersion(type: $args[2]); + }); + + $converter = $this->prophesize(WopiConverter::class); + $converter->convert('fr', '1234', 'application/pdf', 'pdf')->shouldBeCalled() + ->willReturn('5678'); + + $converter = new StoredObjectToPdfConverter($storedObjectManager->reveal(), $converter->reveal(), MimeTypes::getDefault()); + + $actual = $converter->addConvertedVersion($storedObject, 'fr'); + + self::assertIsArray($actual); + self::assertInstanceOf(StoredObjectPointInTime::class, $actual[0]); + self::assertSame($currentVersion, $actual[0]->getObjectVersion()); + self::assertInstanceOf(StoredObjectVersion::class, $actual[1]); + } +} From 941444b7d5b0d8b320f8c4bb061a0935748d4e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 10 Sep 2024 14:29:03 +0200 Subject: [PATCH 6/6] Add event subscriber to convert docs to PDF before signature Introduce ConvertToPdfBeforeSignatureStepEventSubscriber to convert documents to PDF when reaching a signature step in the workflow. Includes tests to ensure the conversion process only triggers when necessary. --- ...BeforeSignatureStepEventSubscriberTest.php | 208 ++++++++++++++++++ ...oPdfBeforeSignatureStepEventSubscriber.php | 75 +++++++ 2 files changed, 283 insertions(+) create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php create mode 100644 src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php new file mode 100644 index 000000000..572cb192c --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriberTest.php @@ -0,0 +1,208 @@ +registerVersion(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldBeCalledOnce() + ->will(function ($args) { + /** @var StoredObject $storedObject */ + $storedObject = $args[0]; + + $pointInTime = new StoredObjectPointInTime($storedObject->getCurrentVersion(), StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION); + $newVersion = $storedObject->registerVersion(filename: 'next'); + + return [$pointInTime, $newVersion]; + }); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + self::assertNotSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertTrue($previousVersion->hasPointInTimes()); + self::assertCount(2, $storedObject->getVersions()); + self::assertEquals('next', $storedObject->getCurrentVersion()->getFilename()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToNotASignatureStep(): void + { + $entityWorkflow = new EntityWorkflow(); + $storedObject = new StoredObject(); + $previousVersion = $storedObject->registerVersion(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_something', ['context' => $dto, 'transition' => 'to_something', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('something', $entityWorkflow->getStep()); + self::assertSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertFalse($previousVersion->hasPointInTimes()); + self::assertCount(1, $storedObject->getVersions()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureAlreadyAPdf(): void + { + $entityWorkflow = new EntityWorkflow(); + $storedObject = new StoredObject(); + $previousVersion = $storedObject->registerVersion(type: 'application/pdf'); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion($storedObject, 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($storedObject); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + self::assertSame($previousVersion, $storedObject->getCurrentVersion()); + self::assertFalse($previousVersion->hasPointInTimes()); + self::assertCount(1, $storedObject->getVersions()); + } + + public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignatureWithNoStoredObject(): void + { + $entityWorkflow = new EntityWorkflow(); + + $converter = $this->prophesize(StoredObjectToPdfConverter::class); + $converter->addConvertedVersion(Argument::type(StoredObject::class), 'fr', 'pdf') + ->shouldNotBeCalled(); + + $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); + $entityWorkflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn(null); + + $request = new Request(); + $request->setLocale('fr'); + $stack = new RequestStack(); + $stack->push($request); + + $eventSubscriber = new ConvertToPdfBeforeSignatureStepEventSubscriber($entityWorkflowManager->reveal(), $converter->reveal(), $stack); + + $registry = $this->buildRegistry($eventSubscriber); + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transition' => 'to_signature', 'transitionAt' => new \DateTimeImmutable('now')]); + + self::assertEquals('signature', $entityWorkflow->getStep()); + } + + private function buildRegistry(EventSubscriberInterface $eventSubscriber): Registry + { + $builder = new DefinitionBuilder(); + $builder + ->setInitialPlaces('initial') + ->addPlaces(['initial', 'signature', 'something']) + ->addTransition(new Transition('to_something', 'initial', 'something')) + ->addTransition(new Transition('to_signature', 'initial', 'signature')); + + $metadataStore = new InMemoryMetadataStore([], ['signature' => ['isSignature' => ['user']]]); + $builder->setMetadataStore($metadataStore); + + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber($eventSubscriber); + + $workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), $eventDispatcher, 'dummy'); + + $supports = new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + }; + + $registry = new Registry(); + $registry->addWorkflow($workflow, $supports); + + return $registry; + } +} diff --git a/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php b/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php new file mode 100644 index 000000000..0d5fe9323 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Workflow/ConvertToPdfBeforeSignatureStepEventSubscriber.php @@ -0,0 +1,75 @@ + 'convertToPdfBeforeSignatureStepEvent', + ]; + } + + public function convertToPdfBeforeSignatureStepEvent(CompletedEvent $event): void + { + $entityWorkflow = $event->getSubject(); + if (!$entityWorkflow instanceof EntityWorkflow) { + return; + } + + $tos = $event->getTransition()->getTos(); + $workflow = $event->getWorkflow(); + $metadataStore = $workflow->getMetadataStore(); + + foreach ($tos as $to) { + $metadata = $metadataStore->getPlaceMetadata($to); + if (array_key_exists('isSignature', $metadata) && 0 < count($metadata['isSignature'])) { + $this->convertToPdf($entityWorkflow); + + return; + } + } + } + + private function convertToPdf(EntityWorkflow $entityWorkflow): void + { + $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); + + if (null === $storedObject) { + return; + } + + if ('application/pdf' === $storedObject->getCurrentVersion()->getType()) { + return; + } + + $this->storedObjectToPdfConverter->addConvertedVersion($storedObject, $this->requestStack->getCurrentRequest()->getLocale(), 'pdf'); + } +}