Merge branch '297-workflow-en-attente' into 'signature-app-master'

Allow user to put workflow on hold

See merge request Chill-Projet/chill-bundles!718
This commit is contained in:
Julien Fastré 2024-09-06 12:02:40 +00:00
commit dd159f4379
18 changed files with 482 additions and 14 deletions

View File

@ -24,6 +24,7 @@ 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;
@ -283,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
{

View File

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

View File

@ -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,9 @@ class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
return $this->steps->get($this->steps->count() - 2);
}
public function isOnHoldAtCurrentStep(): bool
{
return $this->getCurrentStep()->getHoldsOnStep()->count() > 0;
}
}

View File

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

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table('chill_main_workflow_entity_step_hold')]
#[ORM\UniqueConstraint(name: 'chill_main_workflow_hold_unique_idx', columns: ['step_id', 'byUser_id'])]
class EntityWorkflowStepHold implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
public function __construct(#[ORM\ManyToOne(targetEntity: EntityWorkflowStep::class)]
#[ORM\JoinColumn(nullable: false)]
private EntityWorkflowStep $step, #[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private User $byUser)
{
$step->addOnHold($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getStep(): EntityWorkflowStep
{
return $this->step;
}
public function getByUser(): User
{
return $this->byUser;
}
}

View File

@ -230,7 +230,10 @@ class EntityWorkflowRepository implements ObjectRepository
$qb->where(
$qb->expr()->andX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->isMemberOf(':user', 'step.destUserByAccessKey'),
),
$qb->expr()->isNull('step.transitionAfter'),
$qb->expr()->eq('step.isFinal', "'FALSE'")
)

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<EntityWorkflowStepHold>
*/
class EntityWorkflowStepHoldRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EntityWorkflowStepHold::class);
}
/**
* Find an EntityWorkflowStepHold by its ID.
*/
public function findById(int $id): ?EntityWorkflowStepHold
{
return $this->find($id);
}
/**
* Find all EntityWorkflowStepHold records.
*
* @return EntityWorkflowStepHold[]
*/
public function findAllHolds(): array
{
return $this->findAll();
}
/**
* Find EntityWorkflowStepHold by a specific step.
*
* @return EntityWorkflowStepHold[]
*/
public function findByStep(EntityWorkflowStep $step): array
{
return $this->findBy(['step' => $step]);
}
/**
* Find a single EntityWorkflowStepHold by step and user.
*
* @throws NonUniqueResultException
*/
public function findOneByStepAndUser(EntityWorkflowStep $step, User $user): ?EntityWorkflowStepHold
{
try {
return $this->createQueryBuilder('e')
->andWhere('e.step = :step')
->andWhere('e.byUser = :user')
->setParameter('step', $step)
->setParameter('user', $user)
->getQuery()
->getSingleResult();
} catch (NoResultException) {
return null;
}
}
}

View File

@ -498,6 +498,7 @@ div.workflow {
div.breadcrumb {
display: initial;
margin-bottom: 0;
margin-right: .5rem;
padding-right: 0.5em;
background-color: tint-color($chill-yellow, 90%);
border: 1px solid $chill-yellow;

View File

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

View File

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

View File

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

View File

@ -76,7 +76,11 @@
<p><b>{{ 'workflow.Users allowed to apply transition'|trans }}&nbsp;: </b></p>
<ul>
{% for u in step.destUser %}
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}</li>
<li>{{ u|chill_entity_render_box({'at_date': step.previous.transitionAt}) }}
{% if entity_workflow.isOnHoldAtCurrentStep %}
<span class="badge bg-success rounded-pill" title="{{ 'workflow.On hold'|trans|escape('html_attr') }}">{{ 'workflow.On hold'|trans }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Controller;
use Chill\MainBundle\Controller\WorkflowOnHoldController;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepHold;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class WorkflowOnHoldControllerTest extends TestCase
{
private function buildRegistry(): Registry
{
$definitionBuilder = new DefinitionBuilder();
$definition = $definitionBuilder
->addPlaces(['initial', 'layout', 'sign'])
->addTransition(new Transition('to_layout', 'initial', 'layout'))
->addTransition(new Transition('to_sign', 'initial', 'sign'))
->build();
$workflow = new Workflow($definition, new EntityWorkflowMarkingStore(), name: 'dummy_workflow');
$registry = new Registry();
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
});
return $registry;
}
public function testPutOnHoldPersistence(): void
{
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setWorkflowName('dummy_workflow');
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($user = new User());
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects($this->once())
->method('persist')
->with($this->isInstanceOf(EntityWorkflowStepHold::class));
$entityManager->expects($this->once())
->method('flush');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('generate')
->with('chill_main_workflow_show', ['id' => null])
->willReturn('/some/url');
$controller = new WorkflowOnHoldController($entityManager, $security, $this->buildRegistry(), $urlGenerator);
$request = new Request();
$response = $controller->putOnHold($entityWorkflow, $request);
self::assertEquals(302, $response->getStatusCode());
}
public function testRemoveOnHold(): void
{
$user = new User();
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->setWorkflowName('dummy_workflow');
$onHold = new EntityWorkflowStepHold($step = $entityWorkflow->getCurrentStep(), $user);
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($user);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects($this->once())
->method('remove')
->with($onHold);
$entityManager->expects($this->once())
->method('flush');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('generate')
->with('chill_main_workflow_show', ['id' => null])
->willReturn('/some/url');
$controller = new WorkflowOnHoldController($entityManager, $security, $this->buildRegistry(), $urlGenerator);
$response = $controller->removeOnHold($step);
self::assertEquals(302, $response->getStatusCode());
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240807123801 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create workflow step waiting entity';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE chill_main_workflow_entity_step_hold_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_main_workflow_entity_step_hold (id INT NOT NULL, step_id INT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, byUser_id INT NOT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_1BE2E7C73B21E9C ON chill_main_workflow_entity_step_hold (step_id)');
$this->addSql('CREATE INDEX IDX_1BE2E7CD23C0240 ON chill_main_workflow_entity_step_hold (byUser_id)');
$this->addSql('CREATE INDEX IDX_1BE2E7C3174800F ON chill_main_workflow_entity_step_hold (createdBy_id)');
$this->addSql('CREATE UNIQUE INDEX chill_main_workflow_hold_unique_idx ON chill_main_workflow_entity_step_hold (step_id, byUser_id)');
$this->addSql('COMMENT ON COLUMN chill_main_workflow_entity_step_hold.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7C73B21E9C FOREIGN KEY (step_id) REFERENCES chill_main_workflow_entity_step (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7CD23C0240 FOREIGN KEY (byUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold ADD CONSTRAINT FK_1BE2E7C3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE chill_main_workflow_entity_step_hold_id_seq CASCADE');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7C73B21E9C');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7CD23C0240');
$this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold DROP CONSTRAINT FK_1BE2E7C3174800F');
$this->addSql('DROP TABLE chill_main_workflow_entity_step_hold');
}
}

View File

@ -527,6 +527,9 @@ 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
signature_zone:
title: Appliquer les signatures électroniques