Merge branch '324-convert-to-pdf-on-signature-only' into 'master'

Lors de la procédure de signature, le document ne doit être converti qu'à partir du moment où la première signature est apposée

Closes #324

See merge request Chill-Projet/chill-bundles!758
This commit is contained in:
Julien Fastré 2024-11-15 13:33:09 +00:00
commit f04ef3c3e3
12 changed files with 91 additions and 299 deletions

View File

@ -15,12 +15,14 @@ use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessa
use Chill\DocStoreBundle\Service\Signature\PDFPage;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -39,6 +41,8 @@ class SignatureRequestController
private readonly ChillEntityRenderManagerInterface $entityRender,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
private readonly StoredObjectToPdfConverter $converter,
private readonly EntityManagerInterface $entityManager,
) {}
#[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
@ -53,11 +57,17 @@ class SignatureRequestController
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
return new JsonResponse([], status: Response::HTTP_CONFLICT);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
$content = $this->storedObjectManager->read($storedObject);
$data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); // TODO parse payload: json_decode ou, mieux, dataTransfertObject
if ('application/pdf' !== $storedObject->getType()) {
[$storedObject, $storedObjectVersion, $content] = $this->converter->addConvertedVersion($storedObject, $request->getLocale(), includeConvertedContent: true);
$this->entityManager->persist($storedObjectVersion);
$this->entityManager->flush();
} else {
$content = $this->storedObjectManager->read($storedObject);
}
$data = \json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$zone = new PDFSignatureZone(
$data['zone']['index'],
$data['zone']['x'],

View File

@ -277,7 +277,7 @@ console.log(PdfWorker); // incredible but this is needed
// pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker;
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { download_and_decrypt_doc } from "../StoredObjectButton/helpers";
import {download_and_decrypt_doc, download_doc_as_pdf} from "../StoredObjectButton/helpers";
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
@ -385,10 +385,7 @@ const init = () => downloadAndOpen().then(initPdf);
async function downloadAndOpen(): Promise<Blob> {
let raw;
try {
raw = await download_and_decrypt_doc(
signature.storedObject,
signature.storedObject.currentVersion
);
raw = await download_doc_as_pdf(signature.storedObject);
} catch (e) {
console.error("error while downloading and decrypting document", e);
throw e;

View File

@ -3,7 +3,7 @@
<i class="fa fa-download"></i>
<template v-if="displayActionStringInButton">Télécharger</template>
</a>
<a v-else :class="props.classes" target="_blank" :type="props.storedObject.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button" title="Ouvrir">
<a v-else :class="props.classes" target="_blank" :type="props.atVersion.type" :download="buildDocumentName()" :href="state.href_url" ref="open_button" title="Ouvrir">
<i class="fa fa-external-link"></i>
<template v-if="displayActionStringInButton">Ouvrir</template>
</a>

View File

@ -55,7 +55,7 @@ const classes = computed<{row: true, 'row-hover': true, 'blinking-1': boolean, '
<restore-version-button :stored-object-version="props.version" @restore-version="onRestore"></restore-version-button>
</li>
<li>
<download-button :stored-object="storedObject" :at-version="version" :classes="{btn: true, 'btn-outline-primary': true}" :display-action-string-in-button="false"></download-button>
<download-button :stored-object="storedObject" :at-version="version" :classes="{btn: true, 'btn-outline-primary': true, 'btn-sm': true}" :display-action-string-in-button="false"></download-button>
</li>
</ul>
</div>

View File

@ -197,6 +197,32 @@ async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: n
}
}
/**
* Fetch the stored object as a pdf.
*
* If the document is already in a pdf on the server side, the document is retrieved "as is" from the usual
* storage.
*/
async function download_doc_as_pdf(storedObject: StoredObject): Promise<Blob>
{
if (null === storedObject.currentVersion) {
throw new Error("the stored object does not count any version");
}
if (storedObject.currentVersion?.type === 'application/pdf') {
return download_and_decrypt_doc(storedObject, storedObject.currentVersion);
}
const convertLink = build_convert_link(storedObject.uuid);
const response = await fetch(convertLink);
if (!response.ok) {
throw new Error("Could not convert the document: " + response.status);
}
return response.blob();
}
async function is_object_ready(storedObject: StoredObject): Promise<StoredObjectStatusChange>
{
const new_status_response = await window
@ -214,6 +240,7 @@ export {
build_wopi_editor_link,
download_and_decrypt_doc,
download_doc,
download_doc_as_pdf,
is_extension_editable,
is_extension_viewable,
is_object_ready,

View File

@ -17,15 +17,30 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\WopiBundle\Service\WopiConverter;
use Symfony\Contracts\Translation\LocaleAwareInterface;
class PDFSignatureZoneAvailable
class PDFSignatureZoneAvailable implements LocaleAwareInterface
{
private string $locale;
public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly PDFSignatureZoneParser $pdfSignatureZoneParser,
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly WopiConverter $converter,
) {}
public function setLocale(string $locale)
{
$this->locale = $locale;
}
public function getLocale()
{
return $this->locale;
}
/**
* @return list<PDFSignatureZone>
*/
@ -38,10 +53,16 @@ class PDFSignatureZoneAvailable
}
if ('application/pdf' !== $storedObject->getType()) {
throw new \RuntimeException('Only PDF documents are supported');
$content = $this->converter->convert($this->getLocale(), $this->storedObjectManager->read($storedObject), $storedObject->getType());
} else {
$content = $this->storedObjectManager->read($storedObject);
}
$zones = $this->pdfSignatureZoneParser->findSignatureZones($this->storedObjectManager->read($storedObject));
$zones = $this->pdfSignatureZoneParser->findSignatureZones($content);
// free some memory as soon as possible...
unset($content);
$signatureZonesIndexes = array_map(
fn (EntityWorkflowStepSignature $step) => $step->getZoneSignatureIndex(),
$this->collectSignaturesInUse($entityWorkflow)

View File

@ -39,13 +39,13 @@ class StoredObjectToPdfConverter
* @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
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion, 2?: string} contains the point in time before conversion and the new version of the stored object. The converted content is included in the response if $includeConvertedContent is true
*
* @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
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array
{
$newMimeType = $this->mimeTypes->getMimeTypes($convertTo)[0] ?? null;
@ -70,6 +70,11 @@ class StoredObjectToPdfConverter
$pointInTime = new StoredObjectPointInTime($currentVersion, StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION);
$version = $this->storedObjectManager->write($storedObject, $converted, $newMimeType);
return [$pointInTime, $version];
if (!$includeConvertedContent) {
return [$pointInTime, $version];
}
return [$pointInTime, $version, $converted];
}
}

View File

@ -22,6 +22,7 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use Chill\WopiBundle\Service\WopiConverter;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
@ -65,6 +66,7 @@ class PDFSignatureZoneAvailableTest extends TestCase
$entityWorkflowManager->reveal(),
$parser->reveal(),
$storedObjectManager->reveal(),
$this->prophesize(WopiConverter::class)->reveal(),
);
$actual = $filter->getAvailableSignatureZones($entityWorkflow);

View File

@ -1,208 +0,0 @@
<?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

@ -1,75 +0,0 @@
<?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

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\WopiBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\StoredObjectManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\WopiBundle\Service\WopiConverter;
@ -41,7 +42,16 @@ class ConvertController
throw new AccessDeniedHttpException('User must be authenticated');
}
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
throw new AccessDeniedHttpException('not allowed to see this document');
}
$content = $this->storedObjectManager->read($storedObject);
if ('application/pdf' === $storedObject->getType()) {
return new Response($content, Response::HTTP_OK, ['Content-Type' => 'application/pdf']);
}
$lang = $request->getLocale();
try {

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\WopiBundle\Tests\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\WopiBundle\Controller\ConvertController;
use Chill\WopiBundle\Service\WopiConverter;
@ -37,6 +38,7 @@ final class ConvertControllerTest extends TestCase
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)->willReturn(true);
$storeManager = $this->prophesize(StoredObjectManagerInterface::class);
$storeManager->read($storedObject)->willReturn('content');
@ -67,6 +69,7 @@ final class ConvertControllerTest extends TestCase
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)->willReturn(true);
$storeManager = $this->prophesize(StoredObjectManagerInterface::class);
$storeManager->read($storedObject)->willReturn('content');