Merge branch 'signature-app/add-manual-zone' of https://gitlab.com/Chill-Projet/chill-bundles into signature-app/add-manual-zone

This commit is contained in:
nobohan 2024-09-12 10:46:52 +02:00
commit 0ee91800ab
67 changed files with 2733 additions and 316 deletions

2
.env
View File

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

View File

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

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: \Doctrine\DBAL\Types\Types::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

@ -12,12 +12,11 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
@ -33,7 +32,7 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
private StoredObjectManagerInterface $storedObjectManager,
private EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository,
private EntityManagerInterface $entityManager,
private ClockInterface $clock,
private SignatureStepStateChanger $signatureStepStateChanger,
) {}
public function __invoke(PdfSignedMessage $message): void
@ -54,8 +53,8 @@ final readonly class PdfSignedMessageHandler implements MessageHandlerInterface
$this->storedObjectManager->write($storedObject, $message->content);
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate($this->clock->now());
$signature->setZoneSignatureIndex($message->signatureZoneIndex);
$this->signatureStepStateChanger->markSignatureAsSigned($signature, $message->signatureZoneIndex);
$this->entityManager->flush();
$this->entityManager->clear();
}

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

@ -20,12 +20,12 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
/**
* @internal
@ -45,6 +45,9 @@ class PdfSignedMessageHandlerTest extends TestCase
$entityWorkflow->setStep('new_step', $dto, 'new_transition', new \DateTimeImmutable(), new User());
$step = $entityWorkflow->getCurrentStep();
$signature = $step->getSignatures()->first();
$stateChanger = $this->createMock(SignatureStepStateChanger::class);
$stateChanger->expects(self::once())->method('markSignatureAsSigned')
->with($signature, 99);
$handler = new PdfSignedMessageHandler(
new NullLogger(),
@ -52,15 +55,12 @@ class PdfSignedMessageHandlerTest extends TestCase
$this->buildStoredObjectManager($storedObject, $expectedContent = '1234'),
$this->buildSignatureRepository($signature),
$this->buildEntityManager(true),
new MockClock('now'),
$stateChanger,
);
// we simply call the handler. The mocked StoredObjectManager will check that the "write" method is invoked once
// with the content "1234"
$handler(new PdfSignedMessage(10, 99, $expectedContent));
self::assertEquals('signed', $signature->getState()->value);
self::assertEquals(99, $signature->getZoneSignatureIndex());
}
private function buildSignatureRepository(EntityWorkflowStepSignature $signature): EntityWorkflowStepSignatureRepository

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

@ -220,7 +220,8 @@ final class StoredObjectManagerTest extends TestCase
->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
$method,
'https://example.com/'.$objectName,
new \DateTimeImmutable('1 hours')
new \DateTimeImmutable('1 hours'),
$objectName
));
$storedObjectManager = new StoredObjectManager($httpClient, $tempUrlGenerator);
@ -306,7 +307,8 @@ final class StoredObjectManagerTest extends TestCase
->willReturnCallback(fn (string $method, string $objectName) => new SignedUrl(
$method,
'https://example.com/'.$objectName,
new \DateTimeImmutable('1 hours')
new \DateTimeImmutable('1 hours'),
$objectName
));
$manager = new StoredObjectManager($client, $tempUrlGenerator);

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

@ -14,16 +14,17 @@ namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Form\WorkflowSignatureMetadataType;
use Chill\MainBundle\Form\WorkflowStepType;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowStepSignatureRepository;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Chill\MainBundle\Security\ChillSecurity;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Form\Extension\Core\Type\FormType;
@ -32,6 +33,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Workflow\Registry;
@ -51,6 +53,7 @@ class WorkflowController extends AbstractController
private readonly ChillSecurity $security,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
private readonly ClockInterface $clock,
private readonly EntityWorkflowStepSignatureRepository $entityWorkflowStepSignatureRepository,
) {}
#[Route(path: '/{_locale}/main/workflow/create', name: 'chill_main_workflow_create')]
@ -281,6 +284,9 @@ class WorkflowController extends AbstractController
);
}
/**
* @throws NonUniqueResultException
*/
#[Route(path: '/{_locale}/main/workflow/{id}/show', name: 'chill_main_workflow_show')]
public function show(EntityWorkflow $entityWorkflow, Request $request): Response
{
@ -374,7 +380,20 @@ class WorkflowController extends AbstractController
#[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/metadata', name: 'chill_main_workflow_signature_metadata')]
public function addSignatureMetadata(int $signature_id, Request $request): Response
{
$signature = $this->entityManager->getRepository(EntityWorkflowStepSignature::class)->find($signature_id);
$signature = $this->entityWorkflowStepSignatureRepository->find($signature_id);
if (null === $signature) {
throw new NotFoundHttpException('signature not found');
}
if ($signature->isSigned()) {
$this->addFlash(
'notice',
$this->translator->trans('workflow.signature_zone.already_signed_alert')
);
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()]);
}
if ($signature->getSigner() instanceof User) {
return $this->redirectToRoute('chill_main_workflow_signature_add', [

View File

@ -0,0 +1,98 @@
<?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\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Registry;
class WorkflowOnHoldController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly Registry $registry,
private readonly UrlGeneratorInterface $urlGenerator,
) {}
#[Route(path: '/{_locale}/main/workflow/{id}/hold', name: 'chill_main_workflow_on_hold')]
public function putOnHold(EntityWorkflow $entityWorkflow, Request $request): Response
{
$currentStep = $entityWorkflow->getCurrentStep();
$currentUser = $this->security->getUser();
if (!$currentUser instanceof User) {
throw new AccessDeniedHttpException('only user can put a workflow on hold');
}
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$enabledTransitions = $workflow->getEnabledTransitions($entityWorkflow);
if (0 === count($enabledTransitions)) {
throw new AccessDeniedHttpException('You are not allowed to apply any transitions to this workflow, therefore you cannot toggle the hold status.');
}
$stepHold = new EntityWorkflowStepHold($currentStep, $currentUser);
$this->entityManager->persist($stepHold);
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate(
'chill_main_workflow_show',
['id' => $entityWorkflow->getId()]
)
);
}
#[Route(path: '/{_locale}/main/workflow/{id}/remove_hold', name: 'chill_main_workflow_remove_hold')]
public function removeOnHold(EntityWorkflowStep $entityWorkflowStep): Response
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('only user can remove workflow on hold');
}
if (!$entityWorkflowStep->isOnHoldByUser($user)) {
throw new AccessDeniedHttpException('You are not allowed to remove workflow on hold');
}
$hold = $entityWorkflowStep->getHoldsOnStep()->findFirst(fn (int $index, EntityWorkflowStepHold $entityWorkflowStepHold) => $user === $entityWorkflowStepHold->getByUser());
if (null === $hold) {
// this should not happens...
throw new NotFoundHttpException();
}
$this->entityManager->remove($hold);
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate(
'chill_main_workflow_show',
['id' => $entityWorkflowStep->getEntityWorkflow()->getId()]
)
);
}
}

View File

@ -38,7 +38,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
/**
* @var Collection<int, \Chill\MainBundle\Entity\Workflow\EntityWorkflowComment>
*/
#[ORM\OneToMany(targetEntity: EntityWorkflowComment::class, mappedBy: 'entityWorkflow', orphanRemoval: true)]
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowComment::class, orphanRemoval: true)]
private Collection $comments;
#[ORM\Id]
@ -56,7 +56,7 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
* @var Collection<int, EntityWorkflowStep>&Selectable<int, EntityWorkflowStep>
*/
#[Assert\Valid(traverse: true)]
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowStep::class, cascade: ['persist'], orphanRemoval: true)]
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowStep::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['transitionAt' => \Doctrine\Common\Collections\Criteria::ASC, 'id' => 'ASC'])]
private Collection&Selectable $steps;
@ -339,8 +339,6 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
public function isFreeze(): bool
{
$steps = $this->getStepsChained();
foreach ($this->getStepsChained() as $step) {
if ($step->isFreezeAfter()) {
return true;
@ -350,6 +348,11 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
return false;
}
public function isOnHoldByUser(User $user): bool
{
return $this->getCurrentStep()->isOnHoldByUser($user);
}
public function isUserSubscribedToFinal(User $user): bool
{
return $this->subscriberToFinal->contains($user);
@ -480,4 +483,41 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
return $this->steps->get($this->steps->count() - 2);
}
public function isOnHoldAtCurrentStep(): bool
{
return $this->getCurrentStep()->getHoldsOnStep()->count() > 0;
}
/**
* Determines if the workflow has become stale after a given date.
*
* This function checks the creation date and the transition states of the workflow steps.
* A workflow is considered stale if:
* - The creation date is before the given date and no transitions have occurred since the creation.
* - Or if there are no transitions after the given date.
*
* @param \DateTimeImmutable $at the date to compare against the workflow's status
*
* @return bool true if the workflow is stale after the given date, false otherwise
*/
public function isStaledAt(\DateTimeImmutable $at): bool
{
// if there is no transition since the creation, then the workflow is staled
if ('initial' === $this->getCurrentStep()->getCurrentStep()
&& null === $this->getCurrentStep()->getTransitionAt()
) {
if (null === $this->getCreatedAt()) {
return false;
}
if ($this->getCreatedAt() < $at) {
return true;
}
return false;
}
return $this->getCurrentStepChained()->getPrevious()->getTransitionAt() < $at;
}
}

View File

@ -98,12 +98,19 @@ class EntityWorkflowStep
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
private ?string $transitionByEmail = null;
/**
* @var \Doctrine\Common\Collections\Collection<int, \Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold>
*/
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)]
private Collection $holdsOnStep;
public function __construct()
{
$this->ccUser = new ArrayCollection();
$this->destUser = new ArrayCollection();
$this->destUserByAccessKey = new ArrayCollection();
$this->signatures = new ArrayCollection();
$this->holdsOnStep = new ArrayCollection();
$this->accessKey = bin2hex(openssl_random_pseudo_bytes(32));
}
@ -279,6 +286,17 @@ class EntityWorkflowStep
return $this->freezeAfter;
}
public function isOnHoldByUser(User $user): bool
{
foreach ($this->getHoldsOnStep() as $onHold) {
if ($onHold->getByUser() === $user) {
return true;
}
}
return false;
}
public function isWaitingForTransition(): bool
{
if (null !== $this->transitionAfter) {
@ -413,6 +431,11 @@ class EntityWorkflowStep
return $this;
}
public function getHoldsOnStep(): Collection
{
return $this->holdsOnStep;
}
#[Assert\Callback]
public function validateOnCreation(ExecutionContextInterface $context, mixed $payload): void
{
@ -432,4 +455,13 @@ class EntityWorkflowStep
}
}
}
public function addOnHold(EntityWorkflowStepHold $onHold): self
{
if (!$this->holdsOnStep->contains($onHold)) {
$this->holdsOnStep->add($onHold);
}
return $this;
}
}

View File

@ -0,0 +1,54 @@
<?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\MainBundle\Entity\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table('chill_main_workflow_entity_step_hold')]
#[ORM\UniqueConstraint(name: 'chill_main_workflow_hold_unique_idx', columns: ['step_id', 'byUser_id'])]
class EntityWorkflowStepHold 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: EntityWorkflowStep::class)]
#[ORM\JoinColumn(nullable: false)]
private EntityWorkflowStep $step, #[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private User $byUser)
{
$step->addOnHold($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getStep(): EntityWorkflowStep
{
return $this->step;
}
public function getByUser(): User
{
return $this->byUser;
}
}

View File

@ -105,6 +105,11 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
return $this->state;
}
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setState(EntityWorkflowSignatureStateEnum $state): EntityWorkflowStepSignature
{
$this->state = $state;
@ -117,6 +122,11 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
return $this->stateDate;
}
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setStateDate(?\DateTimeImmutable $stateDate): EntityWorkflowStepSignature
{
$this->stateDate = $stateDate;
@ -129,10 +139,58 @@ class EntityWorkflowStepSignature implements TrackCreationInterface, TrackUpdate
return $this->zoneSignatureIndex;
}
/**
* @return $this
*
* @internal You should not use this method directly, use @see{Chill\MainBundle\Workflow\SignatureStepStateChanger} instead
*/
public function setZoneSignatureIndex(?int $zoneSignatureIndex): EntityWorkflowStepSignature
{
$this->zoneSignatureIndex = $zoneSignatureIndex;
return $this;
}
public function isSigned(): bool
{
return EntityWorkflowSignatureStateEnum::SIGNED == $this->getState();
}
public function isPending(): bool
{
return EntityWorkflowSignatureStateEnum::PENDING == $this->getState();
}
/**
* Checks whether all signatures associated with a given workflow step are not pending.
*
* Iterates over each signature in the provided workflow step, and returns false if any signature
* is found to be pending. If all signatures are not pending, returns true.
*
* @param EntityWorkflowStep $step the workflow step whose signatures are to be checked
*
* @return bool true if all signatures are not pending, false otherwise
*/
public static function isAllSignatureNotPendingForStep(EntityWorkflowStep $step): bool
{
foreach ($step->getSignatures() as $signature) {
if ($signature->isPending()) {
return false;
}
}
return true;
}
/**
* @return 'person'|'user'
*/
public function getSignerKind(): string
{
if ($this->personSigner instanceof Person) {
return 'person';
}
return 'user';
}
}

View File

@ -198,6 +198,34 @@ class EntityWorkflowRepository implements ObjectRepository
return $this->repository->findOneBy($criteria);
}
/**
* Finds workflows that are not finalized and are older than the specified date.
*
* @param \DateTimeImmutable $olderThanDate the date to compare against
*
* @return list<int> the list of workflow IDs that meet the criteria
*/
public function findWorkflowsWithoutFinalStepAndOlderThan(\DateTimeImmutable $olderThanDate): array
{
$qb = $this->repository->createQueryBuilder('sw');
$qb->select('sw.id')
// only the workflow which are not finalized
->where('NOT EXISTS (SELECT 1 FROM chill_main_entity_workflow_step ews WHERE ews.isFinal = TRUE AND ews.entityWorkflow = sw.id)')
->andWhere(
$qb->expr()->orX(
// only the workflow where all the last transition is older than transitionAt
':olderThanDate > ALL (SELECT ews.transitionAt FROM chill_main_entity_workflow_step ews WHERE ews.transitionAt IS NOT NULL AND ews.entityWorkflow = sw.id)',
// or the workflow which have only the initial step, with no transition
'1 = (SELECT COUNT(ews.id) FROM chill_main_entity_workflow_step ews WHERE ews.step = :initial AND ews.transitionAt IS NULL AND ews.createdAt < :olderThanDate AND ews.entityWorkflow = sw.id)',
)
)
->andWhere('sw.createdAt < :olderThanDate')
->setParameter('olderThanDate', $olderThanDate);
return $qb->getQuery()->getResult();
}
public function getClassName(): string
{
return EntityWorkflow::class;
@ -230,7 +258,10 @@ class EntityWorkflowRepository implements ObjectRepository
$qb->where(
$qb->expr()->andX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->isMemberOf(':user', 'step.destUserByAccessKey'),
),
$qb->expr()->isNull('step.transitionAfter'),
$qb->expr()->eq('step.isFinal', "'FALSE'")
)

View File

@ -0,0 +1,79 @@
<?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\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<EntityWorkflowStepHold>
*/
class EntityWorkflowStepHoldRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EntityWorkflowStepHold::class);
}
/**
* Find an EntityWorkflowStepHold by its ID.
*/
public function findById(int $id): ?EntityWorkflowStepHold
{
return $this->find($id);
}
/**
* Find all EntityWorkflowStepHold records.
*
* @return EntityWorkflowStepHold[]
*/
public function findAllHolds(): array
{
return $this->findAll();
}
/**
* Find EntityWorkflowStepHold by a specific step.
*
* @return EntityWorkflowStepHold[]
*/
public function findByStep(EntityWorkflowStep $step): array
{
return $this->findBy(['step' => $step]);
}
/**
* Find a single EntityWorkflowStepHold by step and user.
*
* @throws NonUniqueResultException
*/
public function findOneByStepAndUser(EntityWorkflowStep $step, User $user): ?EntityWorkflowStepHold
{
try {
return $this->createQueryBuilder('e')
->andWhere('e.step = :step')
->andWhere('e.byUser = :user')
->setParameter('step', $step)
->setParameter('user', $user)
->getQuery()
->getSingleResult();
} catch (NoResultException) {
return null;
}
}
}

View File

@ -31,6 +31,8 @@
// Specific templates
@import './scss/notification';
@import './scss/hover.scss';
/*
* BASE LAYOUT POSITION
*/
@ -496,6 +498,7 @@ div.workflow {
div.breadcrumb {
display: initial;
margin-bottom: 0;
margin-right: .5rem;
padding-right: 0.5em;
background-color: tint-color($chill-yellow, 90%);
border: 1px solid $chill-yellow;

View File

@ -0,0 +1,11 @@
.row.row-hover {
padding: 0.3rem;
&:hover {
background-color: $gray-100;
border-top: 1px solid $gray-400;
border-bottom: 1px solid $gray-400;
}
}

View File

@ -17,6 +17,10 @@ ul.record_actions {
display: inline-block;
}
&.slim {
margin-bottom: 0;
}
&.column {
flex-direction: column;
}

View File

@ -9,13 +9,16 @@
</template>
<template v-slot:tbody>
<tr v-for="(w, i) in workflows.results" :key="`workflow-${i}`">
<td>{{ w.title }}</td>
<td>
{{ w.title }}
</td>
<td>
<div class="workflow">
<div class="breadcrumb">
<i class="fa fa-circle me-1 text-chill-yellow mx-2"></i>
<span class="mx-2">{{ getStep(w) }}</span>
</div>
<span v-if="w.isOnHoldAtCurrentStep" class="badge bg-success rounded-pill">{{ $t('on_hold') }}</span>
</div>
</td>
<td v-if="w.datas.persons !== null">

View File

@ -41,6 +41,7 @@ const appMessages = {
Step: "Étape",
concerned_users: "Usagers concernés",
Object_workflow: "Objet du workflow",
on_hold: "En attente",
show_entity: "Voir {entity}",
the_activity: "l'échange",
the_course: "le parcours",

View File

@ -6,7 +6,7 @@
<div>
<div class="item-row col">
<h2>{{ w.title }}</h2>
<div class="flex-grow-1 ms-3 h3">
<div class="flex-grow-1 ms-3 h3">
<div class="visually-hidden">
{{ w.relatedEntityClass }}
{{ w.relatedEntityId }}
@ -38,6 +38,7 @@
</span>
</template>
</div>
<span v-if="w.isOnHoldAtCurrentStep" class="badge bg-success rounded-pill">{{ $t('on_hold') }}</span>
</div>
<div class="item-row">
@ -73,7 +74,8 @@ const i18n = {
you_subscribed_to_all_steps: "Vous recevrez une notification à chaque étape",
you_subscribed_to_final_step: "Vous recevrez une notification à l'étape finale",
by: "Par",
at: "Le"
at: "Le",
on_hold: "En attente"
}
}
}

View File

@ -300,7 +300,96 @@
</ul>
</div>
<h3><code>slim</code></h3>
<p>Ajouter <code>slim</code> enlève la marge inférieure. Permet un meilleur alignement horizontal dans une <code>row</code></p>
<div class="container">
<div class="row row-hover">
<div class="col-8">
Some text, ul_record_actions sans slim
</div>
<div class="col-4">
<ul class="record_actions">
<li><button class="btn">Some action</button></li>
</ul>
</div>
</div>
<div class="row row-hover">
<div class="col-8">
Some text, ul_record_actions avec slim
</div>
<div class="col-4">
<ul class="record_actions slim">
<li><button class="btn">Some action</button></li>
</ul>
</div>
</div>
</div>
<xmp><a class="btn btn-submit">Text</a></xmp>
Toutes les classes btn-* de bootstrap sont fonctionnelles
<h2>Hover</h2>
<p>Ajouter <code>.row-hover</code> sur une class <code>.row</code> provoque un changement de background au survol</p>
<div class="container">
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="329" data-action="show" data-button-text="Fatoumata Binta DIALLO (33 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Fatoumata Binta DIALLO (33 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<span class="text-end">A signé le 04/09/2024 à 13:55</span>
</div>
</div>
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="330" data-action="show" data-button-text="Abdoulaye DIALLO (9 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Abdoulaye DIALLO (9 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<ul class="record_actions">
<li>
<a class="btn btn-misc" href="/fr/main/workflow/signature/6/metadata?returnPath=/fr/main/workflow/15/show"><i class="fa fa-pencil-square-o"></i> Signer</a>
</li>
</ul>
</div>
</div>
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="332" data-action="show" data-button-text="Mohamed DIALLO (44 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Mohamed DIALLO (44 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<span class="text-end">A signé le 04/09/2024 à 13:57</span>
</div>
</div>
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="333" data-action="show" data-button-text="Fatou DIALLO (37 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Fatou DIALLO (37 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<ul class="record_actions">
<li>
<a class="btn btn-misc" href="/fr/main/workflow/signature/8/metadata?returnPath=/fr/main/workflow/15/show"><i class="fa fa-pencil-square-o"></i> Signer</a>
</li>
</ul>
</div>
</div>
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
<span class="onthefly-container" data-target-name="person" data-target-id="334" data-action="show" data-button-text="Fanta DIALLO (7 ans)" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span data-v-0c1a1125="" class="chill-entity entity-person badge-person">Fanta DIALLO (7 ans)<!--v-if--></span></a><!--teleport start--><!--teleport end--></span>
</div>
<div class="col-sm-12 col-md-4">
<ul class="record_actions">
<li>
<a class="btn btn-misc" href="/fr/main/workflow/signature/9/metadata?returnPath=/fr/main/workflow/15/show"><i class="fa fa-pencil-square-o"></i> Signer</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -40,11 +40,13 @@
{% set forward = workflow_metadata(step.entityWorkflow, 'isForward', transition) %}
<div class="item-row separator">
<div class="item-col" style="width: inherit;">
{% if step.transitionBy is not null %}
<div>
{{ step.transitionBy|chill_entity_render_box({'at_date': step.transitionAt}) }}
{%- if step.transitionBy is not null -%}
{{ step.transitionBy|chill_entity_render_box({'at_date': step.transitionAt}) }}
{% else %}
<span class="chill-no-data-statement">{{ 'workflow.Automated transition'|trans }}</span>
{%- endif -%}
</div>
{% endif %}
<div>
<span>{{ step.transitionAt|format_datetime('long', 'medium') }}</span>
</div>
@ -76,7 +78,11 @@
<p><b>{{ 'workflow.Users allowed to apply transition'|trans }}&nbsp;: </b></p>
<ul>
{% for u in step.destUser %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</li>
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}
{% if entity_workflow.isOnHoldAtCurrentStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}

View File

@ -1,20 +1,39 @@
<h2>{{ 'workflow.signature_zone.title'|trans }}</h2>
<div class="container">
<div class="row align-items-center">
{% for s in signatures %}
<div class="col-sm-12 col-md-8"><span>{{ s.signer|chill_entity_render_box }}</span></div>
<div class="col-sm-12 col-md-4">
<ul class="record_actions">
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_metadata', { 'signature_id': s.id}) }}"><i class="fa fa-pencil-square-o"></i> {{ 'workflow.signature_zone.button_sign'|trans }}</a>
{% if s.state is same as('signed') %}
<p class="updatedBy">{{ s.stateDate }}</p>
{% for s in signatures %}
<div class="row row-hover align-items-center">
<div class="col-sm-12 col-md-8">
{% 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 %}
<span class="text-end">{{ 'workflow.signature_zone.has_signed_statement'|trans({ 'datetime' : s.stateDate }) }}</span>
{% else %}
<ul class="record_actions slim">
<li>
<a class="btn btn-misc" href="{{ chill_path_add_return_path('chill_main_workflow_signature_metadata', { 'signature_id': s.id}) }}"><i class="fa fa-pencil-square-o"></i> {{ 'workflow.signature_zone.button_sign'|trans }}</a>
{% if s.state is same as('signed') %}
<p class="updatedBy">{{ s.stateDate }}</p>
{% endif %}
</li>
</ul>
{% endif %}
</li>
</ul>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>

View File

@ -39,6 +39,9 @@
<h2>{{ handler.entityTitle(entity_workflow) }}</h2>
{{ macro.breadcrumb({'entity_workflow': entity_workflow}) }}
{% if entity_workflow.isOnHoldAtCurrentStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span>
{% endif %}
</div>
{% include handler_template with handler_template_data|merge({'display_action': true }) %}
@ -64,14 +67,21 @@
<section class="step my-4">{% include '@ChillMain/Workflow/_comment.html.twig' %}</section> #}
<section class="step my-4">{% include '@ChillMain/Workflow/_history.html.twig' %}</section>
{# useful ?
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a class="btn btn-cancel" href="{{ path('chill_main_workflow_list_dest') }}">
{{ 'Back to the list'|trans }}
</a>
</li>
{% if entity_workflow.isOnHoldByUser(app.user) %}
<li>
<a class="btn btn-misc" href="{{ path('chill_main_workflow_remove_hold', {'id': entity_workflow.currentStep.id }) }}"><i class="fa fa-hourglass"></i>
{{ 'workflow.Remove hold'|trans }}
</a>
</li>
{% else %}
<li>
<a class="btn btn-misc" href="{{ path('chill_main_workflow_on_hold', {'id': entity_workflow.id}) }}"><i class="fa fa-hourglass"></i>
{{ 'workflow.Put on hold'|trans }}
</a>
</li>
{% endif %}
</ul>
#}
</div>
{% endblock %}

View File

@ -69,6 +69,9 @@
</button>
<div>
{{ macro.breadcrumb(l) }}
{% if l.entity_workflow.isOnHoldAtCurrentStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span>
{% endif %}
</div>
</div>

View File

@ -3,24 +3,28 @@
{% if step.previous is not null %}
<li>
<span class="item-key">{{ 'By'|trans ~ ' : ' }}</span>
<b>{{ step.previous.transitionBy|chill_entity_render_box({'at_date': step.previous.transitionAt }) }}</b>
<b>{% if step.previous.transitionBy is not null %}{{ step.previous.transitionBy|chill_entity_render_box({'at_date': step.previous.transitionAt }) }}{% else %}<span class="chill-no-data-statement">{{ 'workflow.Automated transition'|trans }}</span>{% endif %}</b>
</li>
<li>
<span class="item-key">{{ 'Le'|trans ~ ' : ' }}</span>
<b>{{ step.previous.transitionAt|format_datetime('short', 'short') }}</b>
</li>
<li>
<span class="item-key">{{ 'workflow.For'|trans ~ ' : ' }}</span>
<b>
{% for d in step.destUser %}{{ d|chill_entity_render_string({'at_date': step.previous.transitionAt}) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b>
</li>
<li>
<span class="item-key">{{ 'workflow.Cc'|trans ~ ' : ' }}</span>
<b>
{% for u in step.ccUser %}{{ u|chill_entity_render_string({'at_date': step.previous.transitionAt }) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b>
</li>
{% if step.destUser|length > 0 %}
<li>
<span class="item-key">{{ 'workflow.For'|trans ~ ' : ' }}</span>
<b>
{% for d in step.destUser %}{{ d|chill_entity_render_string({'at_date': step.previous.transitionAt}) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b>
</li>
{% endif %}
{% if step.ccUser|length > 0 %}
<li>
<span class="item-key">{{ 'workflow.Cc'|trans ~ ' : ' }}</span>
<b>
{% for u in step.ccUser %}{{ u|chill_entity_render_string({'at_date': step.previous.transitionAt }) }}{% if not loop.last %}, {% endif %}{% endfor %}
</b>
</li>
{% endif %}
{% else %}
<li>
<span class="item-key">{{ 'workflow.Created by'|trans ~ ' : ' }}</span>

View File

@ -8,6 +8,6 @@ Vous êtes invités à valider cette étape au plus tôt.
Vous pouvez visualiser le workflow sur cette page:
{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id})) }}
{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': 'fr'})) }}
Cordialement,

View File

@ -6,7 +6,7 @@ Titre du workflow: "{{ entityTitle }}".
Vous êtes invité·e à valider cette étape. Pour obtenir un accès, vous pouvez cliquer sur le lien suivant:
{{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, 'accessKey': entity_workflow.currentStep.accessKey})) }}
{{ absolute_url(path('chill_main_workflow_grant_access_by_key', {'id': entity_workflow.currentStep.id, 'accessKey': entity_workflow.currentStep.accessKey, '_locale': fr})) }}
Dès que vous aurez cliqué une fois sur le lien, vous serez autorisé à valider cette étape.

View File

@ -45,6 +45,7 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn
'steps' => $this->normalizer->normalize($object->getStepsChained(), $format, $context),
'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context),
'title' => $handler->getEntityTitle($object),
'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(),
];
}

View File

@ -0,0 +1,70 @@
<?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\MainBundle\Service\Workflow;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;
class CancelStaleWorkflowCronJob implements CronJobInterface
{
public const KEY = 'remove-stale-workflow';
public const KEEP_INTERVAL = 'P90D';
private const LAST_CANCELED_WORKFLOW = 'last-canceled-workflow-id';
public function __construct(
private readonly EntityWorkflowRepository $workflowRepository,
private readonly ClockInterface $clock,
private readonly MessageBusInterface $messageBus,
private readonly LoggerInterface $logger,
) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
return $this->clock->now() >= $cronJobExecution->getLastEnd()->add(new \DateInterval('P1D'));
}
public function getKey(): string
{
return self::KEY;
}
public function run(array $lastExecutionData): ?array
{
$this->logger->info('Cronjob started: Canceling stale workflows.');
$olderThanDate = $this->clock->now()->sub(new \DateInterval(self::KEEP_INTERVAL));
$staleWorkflowIds = $this->workflowRepository->findWorkflowsWithoutFinalStepAndOlderThan($olderThanDate);
$lastCanceled = $lastExecutionData[self::LAST_CANCELED_WORKFLOW] ?? 0;
$processedCount = 0;
foreach ($staleWorkflowIds as $wId) {
try {
$this->messageBus->dispatch(new CancelStaleWorkflowMessage($wId));
$lastCanceled = max($wId, $lastCanceled);
++$processedCount;
} catch (\Exception $e) {
$this->logger->error("Failed to dispatch CancelStaleWorkflow for ID {$wId}", ['exception' => $e]);
continue;
}
}
$this->logger->info("Cronjob completed: {$processedCount} workflows processed.");
return [self::LAST_CANCELED_WORKFLOW => $lastCanceled];
}
}

View File

@ -0,0 +1,88 @@
<?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\MainBundle\Service\Workflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Workflow\Registry;
#[AsMessageHandler]
final readonly class CancelStaleWorkflowHandler
{
public function __construct(
private EntityWorkflowRepository $workflowRepository,
private Registry $registry,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private ClockInterface $clock,
) {}
public function __invoke(CancelStaleWorkflowMessage $message): void
{
$workflowId = $message->getWorkflowId();
$olderThanDate = $this->clock->now()->sub(new \DateInterval(CancelStaleWorkflowCronJob::KEEP_INTERVAL));
$workflow = $this->workflowRepository->find($message->getWorkflowId());
if (null === $workflow) {
$this->logger->alert('Workflow was not found!', [$workflowId]);
return;
}
if (false === $workflow->isStaledAt($olderThanDate)) {
$this->logger->alert('Workflow has transitioned in the meantime.', [$workflowId]);
throw new UnrecoverableMessageHandlingException('the workflow is not staled any more');
}
$workflowComponent = $this->registry->get($workflow, $workflow->getWorkflowName());
$metadataStore = $workflowComponent->getMetadataStore();
$transitions = $workflowComponent->getEnabledTransitions($workflow);
$transitionApplied = false;
$wasInInitialPosition = 'initial' === $workflow->getStep();
foreach ($transitions as $transition) {
$isFinal = $metadataStore->getMetadata('isFinal', $transition);
$isFinalPositive = $metadataStore->getMetadata('isFinalPositive', $transition);
if ($isFinal && !$isFinalPositive) {
$dto = new WorkflowTransitionContextDTO($workflow);
$workflowComponent->apply($workflow, $transition->getName(), [
'context' => $dto,
'byUser' => null,
'transitionAt' => $this->clock->now(),
'transition' => $transition->getName(),
]);
$this->logger->info('EntityWorkflow has been cancelled automatically.', [$workflowId]);
$transitionApplied = true;
break;
}
}
if (!$transitionApplied) {
$this->logger->error('No valid transition found for EntityWorkflow.', [$workflowId]);
throw new UnrecoverableMessageHandlingException(sprintf('No valid transition found for EntityWorkflow %d.', $workflowId));
}
if ($wasInInitialPosition) {
$this->em->remove($workflow);
}
$this->em->flush();
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\Workflow;
class CancelStaleWorkflowMessage
{
public function __construct(public int $workflowId) {}
public function getWorkflowId(): int
{
return $this->workflowId;
}
}

View File

@ -0,0 +1,116 @@
<?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\MainBundle\Tests\Controller;
use Chill\MainBundle\Controller\WorkflowOnHoldController;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\DefinitionBuilder;
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 WorkflowOnHoldControllerTest extends TestCase
{
private function buildRegistry(): Registry
{
$definitionBuilder = new DefinitionBuilder();
$definition = $definitionBuilder
->addPlaces(['initial', 'layout', 'sign'])
->addTransition(new Transition('to_layout', 'initial', 'layout'))
->addTransition(new Transition('to_sign', 'initial', 'sign'))
->build();
$workflow = new Workflow($definition, new EntityWorkflowMarkingStore(), name: 'dummy_workflow');
$registry = new Registry();
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
});
return $registry;
}
public function testPutOnHoldPersistence(): void
{
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setWorkflowName('dummy_workflow');
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($user = new User());
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects($this->once())
->method('persist')
->with($this->isInstanceOf(EntityWorkflowStepHold::class));
$entityManager->expects($this->once())
->method('flush');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('generate')
->with('chill_main_workflow_show', ['id' => null])
->willReturn('/some/url');
$controller = new WorkflowOnHoldController($entityManager, $security, $this->buildRegistry(), $urlGenerator);
$request = new Request();
$response = $controller->putOnHold($entityWorkflow, $request);
self::assertEquals(302, $response->getStatusCode());
}
public function testRemoveOnHold(): void
{
$user = new User();
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setWorkflowName('dummy_workflow');
$onHold = new EntityWorkflowStepHold($step = $entityWorkflow->getCurrentStep(), $user);
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($user);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects($this->once())
->method('remove')
->with($onHold);
$entityManager->expects($this->once())
->method('flush');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('generate')
->with('chill_main_workflow_show', ['id' => null])
->willReturn('/some/url');
$controller = new WorkflowOnHoldController($entityManager, $security, $this->buildRegistry(), $urlGenerator);
$response = $controller->removeOnHold($step);
self::assertEquals(302, $response->getStatusCode());
}
}

View File

@ -138,4 +138,31 @@ final class EntityWorkflowTest extends TestCase
self::assertContains($person1, $persons);
self::assertContains($person2, $persons);
}
public function testIsStaledAt(): void
{
$creationDate = new \DateTimeImmutable('2024-01-01');
$firstStepDate = new \DateTimeImmutable('2024-01-02');
$afterFistStep = new \DateTimeImmutable('2024-01-03');
$entityWorkflow = new EntityWorkflow();
self::assertFalse($entityWorkflow->isStaledAt($creationDate), 'an entityWorkflow with null createdAt date should never be staled at initial step');
self::assertFalse($entityWorkflow->isStaledAt($firstStepDate), 'an entityWorkflow with null createdAt date should never be staled at initial step');
self::assertFalse($entityWorkflow->isStaledAt($afterFistStep), 'an entityWorkflow with null createdAt date should never be staled at initial step');
$entityWorkflow->setCreatedAt($creationDate);
self::assertFalse($entityWorkflow->isStaledAt($creationDate), 'an entityWorkflow with no step after initial should be staled');
self::assertTrue($entityWorkflow->isStaledAt($firstStepDate), 'an entityWorkflow with no step after initial should be staled');
self::assertTrue($entityWorkflow->isStaledAt($afterFistStep), 'an entityWorkflow with no step after initial should be staled');
// apply a first step
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('new_step', $dto, 'to_new_step', $firstStepDate);
self::assertFalse($entityWorkflow->isStaledAt($creationDate));
self::assertFalse($entityWorkflow->isStaledAt($firstStepDate));
self::assertTrue($entityWorkflow->isStaledAt($afterFistStep));
}
}

View File

@ -0,0 +1,104 @@
<?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 Services\Workflow;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Service\Workflow\CancelStaleWorkflowCronJob;
use Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage;
use PHPUnit\Framework\MockObject\Exception;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @internal
*
* @coversNothing
*/
class CancelStaleWorkflowCronJobTest extends TestCase
{
/**
* @dataProvider buildTestCanRunData
*
* @throws \Exception
*/
public function testCanRun(?CronJobExecution $cronJobExecution, bool $expected): void
{
$clock = new MockClock(new \DateTimeImmutable('2024-01-01 00:00:00', new \DateTimeZone('+00:00')));
$logger = $this->createMock(LoggerInterface::class);
$cronJob = new CancelStaleWorkflowCronJob($this->createMock(EntityWorkflowRepository::class), $clock, $this->buildMessageBus(), $logger);
self::assertEquals($expected, $cronJob->canRun($cronJobExecution));
}
/**
* @throws \DateMalformedStringException
* @throws \DateInvalidTimeZoneException
* @throws \Exception|Exception
*/
public function testRun(): void
{
$clock = new MockClock((new \DateTimeImmutable('now', new \DateTimeZone('+00:00')))->add(new \DateInterval('P120D')));
$workflowRepository = $this->createMock(EntityWorkflowRepository::class);
$workflowRepository->method('findWorkflowsWithoutFinalStepAndOlderThan')->willReturn([1, 3, 2]);
$messageBus = $this->buildMessageBus(true);
$cronJob = new CancelStaleWorkflowCronJob($workflowRepository, $clock, $messageBus, new NullLogger());
$results = $cronJob->run([]);
// Assert the result has the last canceled workflow ID
self::assertArrayHasKey('last-canceled-workflow-id', $results);
self::assertEquals(3, $results['last-canceled-workflow-id']);
}
/**
* @throws \Exception
*/
public static function buildTestCanRunData(): iterable
{
yield [
(new CronJobExecution('last-canceled-workflow-id'))->setLastEnd(new \DateTimeImmutable('2023-12-31 00:00:00', new \DateTimeZone('+00:00'))),
true,
];
yield [
(new CronJobExecution('last-canceled-workflow-id'))->setLastEnd(new \DateTimeImmutable('2023-12-30 23:59:59', new \DateTimeZone('+00:00'))),
true,
];
yield [
(new CronJobExecution('last-canceled-workflow-id'))->setLastEnd(new \DateTimeImmutable('2023-12-31 00:00:01', new \DateTimeZone('+00:00'))),
false,
];
}
private function buildMessageBus(bool $expectDispatchAtLeastOnce = false): MessageBusInterface
{
$messageBus = $this->createMock(MessageBusInterface::class);
$methodDispatch = match ($expectDispatchAtLeastOnce) {
true => $messageBus->expects($this->atLeastOnce())->method('dispatch')->with($this->isInstanceOf(CancelStaleWorkflowMessage::class)),
false => $messageBus->method('dispatch'),
};
$methodDispatch->willReturnCallback(fn (CancelStaleWorkflowMessage $message) => new Envelope($message));
return $messageBus;
}
}

View File

@ -0,0 +1,161 @@
<?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 Services\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Service\Workflow\CancelStaleWorkflowHandler;
use Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
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 CancelStaleWorkflowHandlerTest extends TestCase
{
use ProphecyTrait;
public function testWorkflowWithOneStepOlderThan90DaysIsCanceled(): void
{
$clock = new MockClock('2024-01-01');
$daysAgos = new \DateTimeImmutable('2023-09-01');
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01'));
$workflow->setStep('step1', new WorkflowTransitionContextDTO($workflow), 'to_step1', $daysAgos, new User());
$em = $this->prophesize(EntityManagerInterface::class);
$em->flush()->shouldBeCalled();
$em->remove($workflow)->shouldNotBeCalled();
$handler = $this->buildHandler($workflow, $em->reveal(), $clock);
$handler(new CancelStaleWorkflowMessage(1));
self::assertEquals('canceled', $workflow->getStep());
self::assertCount(3, $workflow->getSteps());
}
public function testWorkflowNotInStaledHandlerIsUnrecoverable(): void
{
$this->expectException(UnrecoverableMessageHandlingException::class);
$clock = new MockClock('2024-01-01');
$daysAgos = new \DateTimeImmutable('2023-12-31');
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-12-31'));
$workflow->setStep('step1', new WorkflowTransitionContextDTO($workflow), 'to_step1', $daysAgos, new User());
$em = $this->prophesize(EntityManagerInterface::class);
$em->flush()->shouldNotBeCalled();
$em->remove($workflow)->shouldNotBeCalled();
$handler = $this->buildHandler($workflow, $em->reveal(), $clock);
$handler(new CancelStaleWorkflowMessage(1));
self::assertEquals('canceled', $workflow->getStep());
self::assertCount(3, $workflow->getSteps());
}
public function testWorkflowStaledInInitialStateIsCompletelyRemoved(): void
{
$clock = new MockClock('2024-01-01');
$workflow = new EntityWorkflow();
$workflow->setWorkflowName('dummy_workflow');
$workflow->setCreatedAt(new \DateTimeImmutable('2023-09-01'));
$em = $this->prophesize(EntityManagerInterface::class);
$em->flush()->shouldBeCalled();
$em->remove($workflow)->shouldBeCalled();
$handler = $this->buildHandler($workflow, $em->reveal(), $clock);
$handler(new CancelStaleWorkflowMessage(1));
self::assertEquals('canceled', $workflow->getStep());
self::assertCount(2, $workflow->getSteps());
}
private function buildHandler(
EntityWorkflow $entityWorkflow,
EntityManagerInterface $entityManager,
ClockInterface $clock,
): CancelStaleWorkflowHandler {
// set an id for the workflow
$reflection = new \ReflectionClass($entityWorkflow);
$reflection->getProperty('id')->setValue($entityWorkflow, 1);
$repository = $this->prophesize(EntityWorkflowRepository::class);
$repository->find(1)->willReturn($entityWorkflow);
return new CancelStaleWorkflowHandler($repository->reveal(), $this->buildRegistry(), $entityManager, new NullLogger(), $clock);
}
private function buildRegistry(): Registry
{
$definitionBuilder = new DefinitionBuilder();
$definitionBuilder
->setInitialPlaces('initial')
->addPlaces(['initial', 'step1', 'canceled', 'final'])
->addTransition(new Transition('to_step1', 'initial', 'step1'))
->addTransition($cancelInit = new Transition('cancel', 'initial', 'canceled'))
->addTransition($finalizeInit = new Transition('finalize', 'initial', 'final'))
->addTransition($cancelStep1 = new Transition('cancel', 'step1', 'canceled'))
->addTransition($finalizeStep1 = new Transition('finalize', 'step1', 'final'));
$transitionStorage = new \SplObjectStorage();
$transitionStorage->attach($finalizeInit, ['isFinal' => true, 'isFinalPositive' => true]);
$transitionStorage->attach($cancelInit, ['isFinal' => true, 'isFinalPositive' => false]);
$transitionStorage->attach($finalizeStep1, ['isFinal' => true, 'isFinalPositive' => true]);
$transitionStorage->attach($cancelStep1, ['isFinal' => true, 'isFinalPositive' => false]);
$definitionBuilder->setMetadataStore(new InMemoryMetadataStore([], [], $transitionStorage));
$workflow = new Workflow($definitionBuilder->build(), new EntityWorkflowMarkingStore(), null, 'dummy_workflow');
$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,168 @@
<?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\MainBundle\Tests\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Templating\Entity\UserRender;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\EventSubscriber\EntityWorkflowGuardTransition;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
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 EntityWorkflowGuardTransitionTest extends TestCase
{
use ProphecyTrait;
public static function buildRegistry(?EventSubscriberInterface $eventSubscriber): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces(['initial'])
->addPlaces(['initial', 'intermediate', 'step1', 'step2', 'step3'])
->addTransition(new Transition('intermediate', 'initial', 'intermediate'))
->addTransition($transition1 = new Transition('transition1', 'intermediate', 'step1'))
->addTransition($transition2 = new Transition('transition2', 'intermediate', 'step2'))
->addTransition($transition3 = new Transition('transition3', 'intermediate', 'step3'))
;
$transitionMetadata = new \SplObjectStorage();
$transitionMetadata->attach($transition1, ['transitionGuard' => 'only-dest']);
$transitionMetadata->attach($transition2, ['transitionGuard' => 'only-dest+system']);
$transitionMetadata->attach($transition3, ['transitionGuard' => 'system']);
$builder->setMetadataStore(new InMemoryMetadataStore(transitionsMetadata: $transitionMetadata));
if (null !== $eventSubscriber) {
$eventDispatcher = new EventDispatcher();
$eventDispatcher->addSubscriber($eventSubscriber);
}
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), $eventDispatcher ?? null, 'dummy');
$registry = new Registry();
$registry->addWorkflow(
$workflow,
new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
}
);
return $registry;
}
/**
* @dataProvider provideBlockingTransition
*/
public function testTransitionGuardBlocked(EntityWorkflow $entityWorkflow, string $transition, ?User $user, string $uuid): void
{
$userRender = $this->prophesize(UserRender::class);
$userRender->renderString(Argument::type(User::class), [])->willReturn('user-as-string');
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$transitionGuard = new EntityWorkflowGuardTransition($userRender->reveal(), $security->reveal());
$registry = self::buildRegistry($transitionGuard);
$workflow = $registry->get($entityWorkflow, 'dummy');
$context = new WorkflowTransitionContextDTO($entityWorkflow);
self::expectException(NotEnabledTransitionException::class);
try {
$workflow->apply($entityWorkflow, $transition, ['context' => $context, 'byUser' => $user, 'transition' => $transition, 'transitionAt' => new \DateTimeImmutable('now')]);
} catch (NotEnabledTransitionException $e) {
$list = $e->getTransitionBlockerList();
self::assertEquals(1, $list->count());
$list = iterator_to_array($list->getIterator());
self::assertEquals($uuid, $list[0]->getCode());
throw $e;
}
}
/**
* @dataProvider provideValidTransition
*/
public function testTransitionGuardValid(EntityWorkflow $entityWorkflow, string $transition, ?User $user, string $newStep): void
{
$userRender = $this->prophesize(UserRender::class);
$userRender->renderString(Argument::type(User::class), [])->willReturn('user-as-string');
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$transitionGuard = new EntityWorkflowGuardTransition($userRender->reveal(), $security->reveal());
$registry = self::buildRegistry($transitionGuard);
$workflow = $registry->get($entityWorkflow, 'dummy');
$context = new WorkflowTransitionContextDTO($entityWorkflow);
$workflow->apply($entityWorkflow, $transition, ['context' => $context, 'byUser' => $user, 'transition' => $transition, 'transitionAt' => new \DateTimeImmutable('now')]);
self::assertEquals($newStep, $entityWorkflow->getStep());
}
public static function provideBlockingTransition(): iterable
{
yield [self::buildEntityWorkflow([new User()]), 'transition1', new User(), 'f3eeb57c-7532-11ec-9495-e7942a2ac7bc'];
yield [self::buildEntityWorkflow([]), 'transition1', null, 'd9e39a18-704c-11ef-b235-8fe0619caee7'];
yield [self::buildEntityWorkflow([new User()]), 'transition1', null, 'd9e39a18-704c-11ef-b235-8fe0619caee7'];
yield [self::buildEntityWorkflow([$user = new User()]), 'transition3', $user, '5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb'];
}
public static function provideValidTransition(): iterable
{
yield [self::buildEntityWorkflow([$u = new User()]), 'transition1', $u, 'step1'];
yield [self::buildEntityWorkflow([$u = new User()]), 'transition2', $u, 'step2'];
yield [self::buildEntityWorkflow([new User()]), 'transition2', null, 'step2'];
yield [self::buildEntityWorkflow([]), 'transition2', null, 'step2'];
yield [self::buildEntityWorkflow([new User()]), 'transition3', null, 'step3'];
yield [self::buildEntityWorkflow([]), 'transition3', null, 'step3'];
}
public static function buildEntityWorkflow(array $futureDestUsers): EntityWorkflow
{
$registry = self::buildRegistry(null);
$baseContext = ['transition' => 'intermediate', 'transitionAt' => new \DateTimeImmutable()];
// test a user not is destination is blocked
$entityWorkflow = new EntityWorkflow();
$workflow = $registry->get($entityWorkflow, 'dummy');
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers = $futureDestUsers;
$workflow->apply($entityWorkflow, 'intermediate', ['context' => $dto, ...$baseContext]);
return $entityWorkflow;
}
}

View File

@ -0,0 +1,145 @@
<?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\MainBundle\Tests\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\SignatureStepStateChanger;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
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 SignatureStepStateChangerTest extends TestCase
{
public function testMarkSignatureAsSignedScenarioWhichExpectsTransition()
{
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setWorkflowName('dummy');
$registry = $this->buildRegistry();
$workflow = $registry->get($entityWorkflow, 'dummy');
$clock = new MockClock();
$user = new User();
$changer = new SignatureStepStateChanger($registry, $clock);
// move it to signature
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures = [new Person(), new Person()];
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => $clock->now(),
'byUser' => $user, 'transition' => 'to_signature']);
// get the signature created
$signatures = $entityWorkflow->getCurrentStep()->getSignatures();
if (2 !== count($signatures)) {
throw new \LogicException('there should have 2 signatures at this step');
}
// we mark the first signature as signed
$changer->markSignatureAsSigned($signatures[0], 1);
self::assertEquals('signature', $entityWorkflow->getStep(), 'there should have any change in the entity workflow step');
self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[0]->getState());
self::assertEquals(1, $signatures[0]->getZoneSignatureIndex());
self::assertNotNull($signatures[0]->getStateDate());
// we mark the second signature as signed
$changer->markSignatureAsSigned($signatures[1], 2);
self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[1]->getState());
self::assertEquals('post-signature', $entityWorkflow->getStep(), 'the entity workflow step should be post-signature');
self::assertContains($user, $entityWorkflow->getCurrentStep()->getAllDestUser());
self::assertEquals(2, $signatures[1]->getZoneSignatureIndex());
self::assertNotNull($signatures[1]->getStateDate());
}
public function testMarkSignatureAsSignedScenarioWithoutRequiredMetadata()
{
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setWorkflowName('dummy');
$registry = $this->buildRegistry();
$workflow = $registry->get($entityWorkflow, 'dummy');
$clock = new MockClock();
$user = new User();
$changer = new SignatureStepStateChanger($registry, $clock);
// move it to signature
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures = [new Person()];
$workflow->apply($entityWorkflow, 'to_signature-without-metadata', ['context' => $dto, 'transitionAt' => $clock->now(),
'byUser' => $user, 'transition' => 'to_signature-without-metadata']);
// get the signature created
$signatures = $entityWorkflow->getCurrentStep()->getSignatures();
if (1 !== count($signatures)) {
throw new \LogicException('there should have 2 signatures at this step');
}
// we mark the first signature as signed
$changer->markSignatureAsSigned($signatures[0], 1);
self::assertEquals('signature-without-metadata', $entityWorkflow->getStep(), 'there should have any change in the entity workflow step');
self::assertEquals(EntityWorkflowSignatureStateEnum::SIGNED, $signatures[0]->getState());
self::assertEquals(1, $signatures[0]->getZoneSignatureIndex());
self::assertNotNull($signatures[0]->getStateDate());
}
private function buildRegistry(): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces('initial')
->addPlaces(['initial', 'signature', 'signature-without-metadata', 'post-signature'])
->addTransition(new Transition('to_signature', 'initial', 'signature'))
->addTransition(new Transition('to_signature-without-metadata', 'initial', 'signature-without-metadata'))
->addTransition(new Transition('to_post-signature', 'signature', 'post-signature'))
->addTransition(new Transition('to_post-signature_2', 'signature-without-metadata', 'post-signature'))
;
$metadata = new InMemoryMetadataStore(
[],
[
'signature' => ['onSignatureCompleted' => ['transitionName' => 'to_post-signature']],
]
);
$builder->setMetadataStore($metadata);
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy');
$registry = new Registry();
$registry->addWorkflow(
$workflow,
new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
}
);
return $registry;
}
}

View File

@ -0,0 +1,105 @@
<?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\MainBundle\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Templating\Entity\UserRender;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\TransitionBlocker;
class EntityWorkflowGuardTransition implements EventSubscriberInterface
{
public function __construct(
private readonly UserRender $userRender,
private readonly Security $security,
) {}
public static function getSubscribedEvents(): array
{
return [
'workflow.guard' => [
['guardEntityWorkflow', 0],
],
];
}
public function guardEntityWorkflow(GuardEvent $event)
{
if (!$event->getSubject() instanceof EntityWorkflow) {
return;
}
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject();
if ($entityWorkflow->isFinal()) {
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.The workflow is finalized',
'd6306280-7535-11ec-a40d-1f7bee26e2c0'
)
);
return;
}
$user = $this->security->getUser();
$metadata = $event->getWorkflow()->getMetadataStore()->getTransitionMetadata($event->getTransition());
$systemTransitions = explode('+', $metadata['transitionGuard'] ?? 'only-dest');
if (null === $user) {
if (in_array('system', $systemTransitions, true)) {
// it is safe to apply this transition
return;
}
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.Transition is not allowed for system',
'd9e39a18-704c-11ef-b235-8fe0619caee7'
)
);
return;
}
// for users
if (!in_array('only-dest', $systemTransitions, true)) {
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.Only system can apply this transition',
'5b6b95e0-704d-11ef-a5a9-4b6fc11a8eeb'
)
);
}
if (!$entityWorkflow->getCurrentStep()->getAllDestUser()->contains($user)) {
if ($event->getMarking()->has('initial')) {
return;
}
$event->addTransitionBlocker(new TransitionBlocker(
'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%',
'f3eeb57c-7532-11ec-9495-e7942a2ac7bc',
[
'%users%' => implode(
', ',
$entityWorkflow->getCurrentStep()->getAllDestUser()->map(fn (User $u) => $this->userRender->renderString($u, []))->toArray()
),
]
));
}
}
}

View File

@ -13,20 +13,16 @@ namespace Chill\MainBundle\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Templating\Entity\UserRender;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\TransitionBlocker;
final readonly class EntityWorkflowTransitionEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private LoggerInterface $chillLogger,
private Security $security,
private UserRender $userRender,
) {}
public static function getSubscribedEvents(): array
@ -36,48 +32,9 @@ final readonly class EntityWorkflowTransitionEventSubscriber implements EventSub
'workflow.completed' => [
['markAsFinal', 2048],
],
'workflow.guard' => [
['guardEntityWorkflow', 0],
],
];
}
public function guardEntityWorkflow(GuardEvent $event)
{
if (!$event->getSubject() instanceof EntityWorkflow) {
return;
}
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject();
if ($entityWorkflow->isFinal()) {
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.The workflow is finalized',
'd6306280-7535-11ec-a40d-1f7bee26e2c0'
)
);
return;
}
if (!$entityWorkflow->getCurrentStep()->getAllDestUser()->contains($this->security->getUser())) {
if (!$event->getMarking()->has('initial')) {
$event->addTransitionBlocker(new TransitionBlocker(
'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%',
'f3eeb57c-7532-11ec-9495-e7942a2ac7bc',
[
'%users%' => implode(
', ',
$entityWorkflow->getCurrentStep()->getAllDestUser()->map(fn (User $u) => $this->userRender->renderString($u, []))->toArray()
),
]
));
}
}
}
public function markAsFinal(Event $event): void
{
// NOTE: it is not possible to move this method to the marking store, because
@ -109,11 +66,13 @@ final readonly class EntityWorkflowTransitionEventSubscriber implements EventSub
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject();
$user = $this->security->getUser();
$this->chillLogger->info('[workflow] apply transition on entityWorkflow', [
'relatedEntityClass' => $entityWorkflow->getRelatedEntityClass(),
'relatedEntityId' => $entityWorkflow->getRelatedEntityId(),
'transition' => $event->getTransition()->getName(),
'by_user' => $this->security->getUser(),
'by_user' => $user instanceof User ? $user->getId() : (string) $user?->getUserIdentifier(),
'entityWorkflow' => $entityWorkflow->getId(),
]);
}

View File

@ -0,0 +1,112 @@
<?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\MainBundle\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Workflow\Registry;
class SignatureStepStateChanger
{
private const LOG_PREFIX = '[SignatureStepStateChanger] ';
public function __construct(
private readonly Registry $registry,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
) {}
public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): void
{
$signature
->setState(EntityWorkflowSignatureStateEnum::SIGNED)
->setZoneSignatureIndex($atIndex)
->setStateDate($this->clock->now())
;
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as signed', ['signatureId' => $signature->getId(), 'index' => (string) $atIndex]);
if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) {
$this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]);
return;
}
$this->logger->debug(self::LOG_PREFIX.'Continuing the process to find a transition', ['signatureId' => $signature->getId()]);
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$metadataStore = $workflow->getMetadataStore();
// find a transition
$marking = $workflow->getMarking($entityWorkflow);
$places = $marking->getPlaces();
$transition = null;
foreach ($places as $place => $int) {
$metadata = $metadataStore->getPlaceMetadata($place);
if (array_key_exists('onSignatureCompleted', $metadata)) {
$transition = $metadata['onSignatureCompleted']['transitionName'];
}
}
if (null === $transition) {
$this->logger->info(self::LOG_PREFIX.'The transition is not configured, will not apply a transition', ['signatureId' => $signature->getId()]);
return;
}
$previousUser = $this->getPreviousSender($signature->getStep());
if (null === $previousUser) {
$this->logger->info(self::LOG_PREFIX.'No previous user, will not apply a transition', ['signatureId' => $signature->getId()]);
return;
}
$transitionDto = new WorkflowTransitionContextDTO($entityWorkflow);
$transitionDto->futureDestUsers[] = $previousUser;
$workflow->apply($entityWorkflow, $transition, [
'context' => $transitionDto,
'transitionAt' => $this->clock->now(),
'transition' => $transition,
]);
$this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]);
}
private function getPreviousSender(EntityWorkflowStep $entityWorkflowStep): ?User
{
$stepsChained = $entityWorkflowStep->getEntityWorkflow()->getStepsChained();
foreach ($stepsChained as $stepChained) {
if ($stepChained === $entityWorkflowStep) {
if (null === $previous = $stepChained->getPrevious()) {
return null;
}
if (null !== $previousUser = $previous->getTransitionBy()) {
return $previousUser;
}
return $this->getPreviousSender($previous);
}
}
throw new \LogicException('no same step found');
}
}

View File

@ -0,0 +1,46 @@
<?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\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240807123801 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create workflow step waiting entity';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE chill_main_workflow_entity_step_hold_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_main_workflow_entity_step_hold (id INT NOT NULL, step_id INT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, byUser_id INT NOT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_1BE2E7C73B21E9C ON chill_main_workflow_entity_step_hold (step_id)');
$this->addSql('CREATE INDEX IDX_1BE2E7CD23C0240 ON chill_main_workflow_entity_step_hold (byUser_id)');
$this->addSql('CREATE INDEX IDX_1BE2E7C3174800F ON chill_main_workflow_entity_step_hold (createdBy_id)');
$this->addSql('CREATE UNIQUE INDEX chill_main_workflow_hold_unique_idx ON chill_main_workflow_entity_step_hold (step_id, byUser_id)');
$this->addSql('COMMENT ON COLUMN chill_main_workflow_entity_step_hold.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7C73B21E9C FOREIGN KEY (step_id) REFERENCES chill_main_workflow_entity_step (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7CD23C0240 FOREIGN KEY (byUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7C3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE chill_main_workflow_entity_step_hold_id_seq CASCADE');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7C73B21E9C');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7CD23C0240');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7C3174800F');
$this->addSql('DROP TABLE chill_main_workflow_entity_step_hold');
}
}

View File

@ -45,6 +45,9 @@ workflow:
few {# workflows}
other {# workflows}
}
signature_zone:
has_signed_statement: 'A signé le {datetime, date, short} à {datetime, time, short}'
duration:
minute: >-

View File

@ -527,6 +527,10 @@ workflow:
Access link copied: Lien d'accès copié
This link grant any user to apply a transition: Le lien d'accès suivant permet d'appliquer une transition
The workflow may be accssed through this link: Une transition peut être appliquée sur ce workflow grâce au lien d'accès suivant
Put on hold: Mettre en attente
Remove hold: Enlever la mise en attente
On hold: En attente
Automated transition: Transition automatique
signature_zone:
title: Appliquer les signatures électroniques
@ -541,6 +545,7 @@ workflow:
user signature: Selectionner utilisateur pour signer
persons: Usagers
user: Utilisateur
already_signed_alert: La signature a déjà été appliquée
Subscribe final: Recevoir une notification à l'étape finale

View File

@ -1,97 +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\WopiBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
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 Convert
{
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 LoggerInterface $logger,
ParameterBagInterface $parameters,
) {
$this->collaboraDomain = $parameters->get('wopi')['server'];
}
public function __invoke(StoredObject $storedObject): Response
{
if (!$this->security->getUser() instanceof User) {
throw new AccessDeniedHttpException('User must be authenticated');
}
$content = $this->storedObjectManager->read($storedObject);
$query = [];
if (null !== $request = $this->requestStack->getCurrentRequest()) {
$query['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, [
'Content-Type' => 'application/pdf',
]);
} catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $exception) {
return $this->onConversionFailed($url, $exception->getResponse());
}
}
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,57 @@
<?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\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\WopiBundle\Service\WopiConverter;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Security;
class ConvertController
{
private const LOG_PREFIX = '[convert] ';
/**
* @param StoredObjectManager $storedObjectManager
*/
public function __construct(
private readonly Security $security,
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly WopiConverter $wopiConverter,
private readonly LoggerInterface $logger,
) {}
public function __invoke(StoredObject $storedObject, Request $request): Response
{
if (!($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'))) {
throw new AccessDeniedHttpException('User must be authenticated');
}
$content = $this->storedObjectManager->read($storedObject);
$lang = $request->getLocale();
try {
return new Response($this->wopiConverter->convert($lang, $content, $storedObject->getType()), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
]);
} 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);
}
}
}

View File

@ -19,5 +19,5 @@ return static function (RoutingConfigurator $routes) {
$routes
->add('chill_wopi_object_convert', '/convert/{uuid}')
->controller(Chill\WopiBundle\Controller\Convert::class);
->controller(Chill\WopiBundle\Controller\ConvertController::class);
};

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

@ -0,0 +1,93 @@
<?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\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
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\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
final class ConvertControllerTest extends TestCase
{
use ProphecyTrait;
public function testConversionFailed(): void
{
$storedObject = new StoredObject();
$storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text');
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$storeManager = $this->prophesize(StoredObjectManagerInterface::class);
$storeManager->read($storedObject)->willReturn('content');
$wopiConverter = $this->prophesize(WopiConverter::class);
$wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text')
->willThrow(new \RuntimeException());
$controller = new ConvertController(
$security->reveal(),
$storeManager->reveal(),
$wopiConverter->reveal(),
new NullLogger(),
);
$request = new Request();
$request->setLocale('fr');
$response = $controller($storedObject, $request);
$this->assertNotEquals(200, $response->getStatusCode());
}
public function testEverythingWentFine(): void
{
$storedObject = new StoredObject();
$storedObject->registerVersion(type: 'application/vnd.oasis.opendocument.text');
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$storeManager = $this->prophesize(StoredObjectManagerInterface::class);
$storeManager->read($storedObject)->willReturn('content');
$wopiConverter = $this->prophesize(WopiConverter::class);
$wopiConverter->convert('fr', 'content', 'application/vnd.oasis.opendocument.text')
->willReturn('1234');
$controller = new ConvertController(
$security->reveal(),
$storeManager->reveal(),
$wopiConverter->reveal(),
new NullLogger(),
);
$request = new Request();
$request->setLocale('fr');
$response = $controller($storedObject, $request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('1234', $response->getContent());
}
}

View File

@ -1,105 +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\WopiBundle\Tests\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Chill\WopiBundle\Controller\Convert;
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;
/**
* @internal
*
* @coversNothing
*/
final class ConvertTest extends TestCase
{
use ProphecyTrait;
public function testConversionFailed(): void
{
$storedObject = (new StoredObject())->setType('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());
$storeManager = $this->prophesize(StoredObjectManagerInterface::class);
$storeManager->read($storedObject)->willReturn('content');
$parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]);
$convert = new Convert(
$httpClient,
$this->makeRequestStack(),
$security->reveal(),
$storeManager->reveal(),
new NullLogger(),
$parameterBag
);
$response = $convert($storedObject);
$this->assertNotEquals(200, $response->getStatusCode());
}
public function testEverythingWentFine(): void
{
$storedObject = (new StoredObject())->setType('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());
$storeManager = $this->prophesize(StoredObjectManagerInterface::class);
$storeManager->read($storedObject)->willReturn('content');
$parameterBag = new ParameterBag(['wopi' => ['server' => 'http://collabora:9980']]);
$convert = new Convert(
$httpClient,
$this->makeRequestStack(),
$security->reveal(),
$storeManager->reveal(),
new NullLogger(),
$parameterBag
);
$response = $convert($storedObject);
$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);
}
}

View File

@ -0,0 +1,2 @@
wopi:
server: "%env(resolve:EDITOR_SERVER)%"