mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 01:08:26 +00:00 
			
		
		
		
	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:
		
							
								
								
									
										2
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								.env
									
									
									
									
									
								
							| @@ -23,7 +23,7 @@ TRUSTED_HOSTS='^(localhost|example\.com|nginx)$' | ||||
| ###< symfony/framework-bundle ### | ||||
|  | ||||
| ## Wopi server for editing documents online | ||||
| WOPI_SERVER=http://collabora:9980 | ||||
| EDITOR_SERVER=http://collabora:9980 | ||||
|  | ||||
| # must be manually set in .env.local | ||||
| # ADMIN_PASSWORD= | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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'; | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
|   | ||||
| @@ -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); | ||||
|         } | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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]; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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]); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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'); | ||||
|     } | ||||
| } | ||||
| @@ -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'); | ||||
|     } | ||||
| } | ||||
| @@ -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', [ | ||||
|   | ||||
| @@ -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()] | ||||
|             ) | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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'; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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'") | ||||
|             ) | ||||
|   | ||||
| @@ -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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -17,6 +17,10 @@ ul.record_actions { | ||||
|         display: inline-block; | ||||
|     } | ||||
|  | ||||
|     &.slim { | ||||
|         margin-bottom: 0; | ||||
|     } | ||||
|  | ||||
|    &.column { | ||||
|       flex-direction: column; | ||||
|    } | ||||
|   | ||||
| @@ -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"> | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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" | ||||
|       } | ||||
|    } | ||||
| } | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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 }} : </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 %} | ||||
|   | ||||
| @@ -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> | ||||
|  | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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. | ||||
|  | ||||
|   | ||||
| @@ -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(), | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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]; | ||||
|     } | ||||
| } | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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()); | ||||
|     } | ||||
| } | ||||
| @@ -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)); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|                     ), | ||||
|                 ] | ||||
|             )); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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(), | ||||
|         ]); | ||||
|     } | ||||
|   | ||||
| @@ -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'); | ||||
|     } | ||||
| } | ||||
| @@ -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'); | ||||
|     } | ||||
| } | ||||
| @@ -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: >- | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										69
									
								
								src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/Bundle/ChillWopiBundle/src/Service/WopiConverter.php
									
									
									
									
									
										Normal 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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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()); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										2
									
								
								tests/app/config/packages/wopi.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								tests/app/config/packages/wopi.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| wopi: | ||||
|     server: "%env(resolve:EDITOR_SERVER)%" | ||||
		Reference in New Issue
	
	Block a user