Compare commits

...

6 Commits

Author SHA1 Message Date
fad7bdf235 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.
2024-09-10 14:29:20 +02:00
8521cea46c 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.
2024-09-10 14:29:20 +02:00
4ead7ba761 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.
2024-09-10 14:29:20 +02:00
9721b166eb 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.
2024-09-10 14:29:20 +02:00
1b21cd6c33 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.
2024-09-10 14:29:19 +02:00
97860a9487 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".
2024-09-10 10:44:45 +02:00
22 changed files with 831 additions and 99 deletions

View File

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

View File

@@ -0,0 +1,67 @@
<?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\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Mapping as ORM;
/**
* Represents a snapshot of a stored object at a specific point in time.
*
* This entity tracks versions of stored objects, reasons for the snapshot,
* and the user who initiated the action.
*/
#[ORM\Entity]
#[ORM\Table(name: 'stored_object_point_in_time', schema: 'chill_doc')]
class StoredObjectPointInTime implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: StoredObjectVersion::class, inversedBy: 'pointInTimes')]
#[ORM\JoinColumn(name: 'stored_object_version_id', nullable: false)]
private StoredObjectVersion $objectVersion,
#[ORM\Column(name: 'reason', type: 'text', nullable: false, enumType: StoredObjectPointInTimeReasonEnum::class)]
private StoredObjectPointInTimeReasonEnum $reason,
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $byUser = null,
) {
$this->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;
}
}

View File

@@ -0,0 +1,18 @@
<?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\Entity;
enum StoredObjectPointInTimeReasonEnum: string
{
case KEEP_BEFORE_CONVERSION = 'keep-before-conversion';
case KEEP_BY_USER = 'keep-by-user';
}

View File

@@ -13,6 +13,9 @@ namespace Chill\DocStoreBundle\Entity;
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 Random\RandomException;
@@ -39,6 +42,12 @@ class StoredObjectVersion implements TrackCreationInterface
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $filename = '';
/**
* @var Collection<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime>
*/
#[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<int, StoredObjectPointInTime>&Selectable<int, StoredObjectPointInTime>
*/
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;
}
}

View File

@@ -0,0 +1,27 @@
<?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\Repository;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<StoredObjectPointInTime>
*/
class StoredObjectPointInTimeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StoredObjectPointInTime::class);
}
}

View File

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

View File

@@ -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);
}

View File

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

View File

@@ -0,0 +1,75 @@
<?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\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\WopiBundle\Service\WopiConverter;
use Symfony\Component\Mime\MimeTypesInterface;
/**
* Class StoredObjectToPdfConverter.
*
* Converts stored objects to PDF or other specified formats using WopiConverter.
*/
class StoredObjectToPdfConverter
{
public function __construct(
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly WopiConverter $wopiConverter,
private readonly MimeTypesInterface $mimeTypes,
) {}
/**
* Converts the given stored object to a specified format and stores the new version.
*
* @param StoredObject $storedObject the stored object to be converted
* @param string $lang the language for the conversion context
* @param string $convertTo The target format for the conversion. Default is 'pdf'.
*
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion} contains the point in time before conversion and the new version of the stored object
*
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
* @throws \RuntimeException if the conversion or storage of the new version fails
* @throws StoredObjectManagerException
*/
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf'): array
{
$newMimeType = $this->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];
}
}

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
<?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\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\WopiBundle\Service\WopiConverter;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Mime\MimeTypes;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectToPdfConverterTest extends TestCase
{
use ProphecyTrait;
public function testAddConvertedVersion(): void
{
$storedObject = new StoredObject();
$currentVersion = $storedObject->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]);
}
}

View File

@@ -0,0 +1,208 @@
<?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\Workflow;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\DocStoreBundle\Workflow\ConvertToPdfBeforeSignatureStepEventSubscriber;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class ConvertToPdfBeforeSignatureStepEventSubscriberTest extends \PHPUnit\Framework\TestCase
{
use ProphecyTrait;
public function testConvertToPdfBeforeSignatureStepEventSubscriberToSignature(): void
{
$entityWorkflow = new EntityWorkflow();
$storedObject = new StoredObject();
$previousVersion = $storedObject->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;
}
}

View File

@@ -0,0 +1,75 @@
<?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\Workflow;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Workflow\Event\CompletedEvent;
use Symfony\Component\Workflow\WorkflowEvents;
/**
* Event subscriber to convert objects to PDF when the document reach a signature step.
*/
class ConvertToPdfBeforeSignatureStepEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly StoredObjectToPdfConverter $storedObjectToPdfConverter,
private readonly RequestStack $requestStack,
) {}
public static function getSubscribedEvents(): array
{
return [
WorkflowEvents::COMPLETED => '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');
}
}

View File

@@ -0,0 +1,49 @@
<?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 Version20240910093735 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add point in time for stored object version';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -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';
}
}

View File

@@ -4,12 +4,20 @@
{% for s in signatures %}
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
{% 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 %}
</div>
<div class="col-sm-12 col-md-4">
{% if s.isSigned %}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,69 @@
<?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\WopiBundle\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
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;
/**
* Handles the conversion of documents to PDF using the Collabora Online server.
*/
class WopiConverter
{
private readonly string $collaboraDomain;
private const LOG_PREFIX = '[WopiConverterPDF] ';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
ParameterBagInterface $parameters,
) {
$this->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);
}
}
}

View File

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

View File

@@ -0,0 +1,63 @@
<?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\WopiBundle\Tests\Service;
use Chill\WopiBundle\Service\WopiConverter;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* @internal
*
* @coversNothing
*/
class WopiConvertToPdfTest extends TestCase
{
/**
* @group collabora-integration
*/
public function testConvertToPdfWithRealServer(): void
{
$content = file_get_contents(__DIR__.'/fixtures/test-document.odt');
$client = HttpClient::create();
$parameters = new ParameterBag([
'wopi' => ['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);
}
}