Files
chill-bundles/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflow.php
Julien Fastré 9f1afb8423 Add access controls and permissions for signature steps
Implemented a Voter to enforce permissions on signature steps, ensuring only authorized users can sign steps. Updated relevant controllers and templates to reflect these permissions, and added corresponding tests to validate the changes.
2024-09-13 17:04:57 +02:00

528 lines
14 KiB
PHP

<?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\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['entity_workflow' => EntityWorkflow::class])]
#[ORM\Entity]
#[ORM\Table('chill_main_workflow_entity')]
#[EntityWorkflowCreation(groups: ['creation'])]
class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @var Collection<int, \Chill\MainBundle\Entity\Workflow\EntityWorkflowComment>
*/
#[ORM\OneToMany(mappedBy: 'entityWorkflow', targetEntity: EntityWorkflowComment::class, orphanRemoval: true)]
private Collection $comments;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)]
private string $relatedEntityClass = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private int $relatedEntityId;
/**
* @var Collection<int, EntityWorkflowStep>&Selectable<int, EntityWorkflowStep>
*/
#[Assert\Valid(traverse: 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;
/**
* @var array|EntityWorkflowStep[]|null
*/
private ?array $stepsChainedCache = null;
/**
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_subscriber_to_final')]
private Collection $subscriberToFinal;
/**
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_workflow_entity_subscriber_to_step')]
private Collection $subscriberToStep;
/**
* a step which will store all the transition data.
*/
private ?EntityWorkflowStep $transitionningStep = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
private string $workflowName;
public function __construct()
{
$this->subscriberToFinal = new ArrayCollection();
$this->subscriberToStep = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->steps = new ArrayCollection();
$initialStep = new EntityWorkflowStep();
$initialStep
->setCurrentStep('initial');
$this->addStep($initialStep);
}
public function addComment(EntityWorkflowComment $comment): self
{
if (!$this->comments->contains($comment)) {
$this->comments[] = $comment;
$comment->setEntityWorkflow($this);
}
return $this;
}
/**
* @internal You should prepare a step and run a workflow transition instead of manually adding a step
*/
public function addStep(EntityWorkflowStep $step): self
{
if (!$this->steps->contains($step)) {
$this->steps[] = $step;
$step->setEntityWorkflow($this);
$this->stepsChainedCache = null;
}
return $this;
}
public function addSubscriberToFinal(User $user): self
{
if (!$this->subscriberToFinal->contains($user)) {
$this->subscriberToFinal[] = $user;
}
return $this;
}
public function addSubscriberToStep(User $user): self
{
if (!$this->subscriberToStep->contains($user)) {
$this->subscriberToStep[] = $user;
}
return $this;
}
public function getComments(): Collection
{
return $this->comments;
}
public function getCurrentStep(): ?EntityWorkflowStep
{
$step = $this->steps->last();
if (false !== $step) {
return $step;
}
return null;
}
public function getCurrentStepChained(): ?EntityWorkflowStep
{
$steps = $this->getStepsChained();
$currentStep = $this->getCurrentStep();
foreach ($steps as $step) {
if ($step === $currentStep) {
return $step;
}
}
return null;
}
public function getCurrentStepCreatedAt(): ?\DateTimeInterface
{
if (null !== $previous = $this->getPreviousStepIfAny()) {
return $previous->getTransitionAt();
}
return null;
}
public function getCurrentStepCreatedBy(): ?User
{
if (null !== $previous = $this->getPreviousStepIfAny()) {
return $previous->getTransitionBy();
}
return null;
}
public function getId(): ?int
{
return $this->id;
}
public function getRelatedEntityClass(): string
{
return $this->relatedEntityClass;
}
public function getRelatedEntityId(): int
{
return $this->relatedEntityId;
}
/**
* Method used by MarkingStore.
*
* get a string representation of the step
*/
public function getStep(): string
{
return $this->getCurrentStep()->getCurrentStep();
}
public function getStepAfter(EntityWorkflowStep $step): ?EntityWorkflowStep
{
$iterator = $this->steps->getIterator();
if ($iterator instanceof \Iterator) {
$iterator->rewind();
while ($iterator->valid()) {
$curStep = $iterator->current();
if ($curStep === $step) {
$iterator->next();
if ($iterator->valid()) {
return $iterator->current();
}
return null;
}
$iterator->next();
}
return null;
}
throw new \RuntimeException();
}
public function getSteps(): Collection&Selectable
{
return $this->steps;
}
/**
* @throws \Exception
*/
public function getStepsChained(): array
{
if (\is_array($this->stepsChainedCache)) {
return $this->stepsChainedCache;
}
/** @var \ArrayIterator $iterator */
$iterator = $this->steps->getIterator();
$current = null;
$steps = [];
$iterator->rewind();
do {
$previous = $current;
$current = $iterator->current();
$steps[] = $current;
$current->setPrevious($previous);
$iterator->next();
if ($iterator->valid()) {
$current->setNext($iterator->current());
} else {
$current->setNext(null);
}
} while ($iterator->valid());
$this->stepsChainedCache = $steps;
return $steps;
}
public function getSubscriberToFinal(): ArrayCollection|Collection
{
return $this->subscriberToFinal;
}
public function getSubscriberToStep(): ArrayCollection|Collection
{
return $this->subscriberToStep;
}
/**
* get the step which is transitionning. Should be called only by event which will
* concern the transition.
*/
public function getTransitionningStep(): ?EntityWorkflowStep
{
return $this->transitionningStep;
}
/**
* @return User[]
*/
public function getUsersInvolved(): array
{
$usersInvolved = [];
$usersInvolved[spl_object_hash($this->getCreatedBy())] = $this->getCreatedBy();
foreach ($this->steps as $step) {
foreach ($step->getDestUser() as $u) {
$usersInvolved[spl_object_hash($u)] = $u;
}
}
return array_values($usersInvolved);
}
public function getWorkflowName(): string
{
return $this->workflowName;
}
public function isFinal(): bool
{
foreach ($this->getStepsChained() as $step) {
if ($step->isFinal()) {
return true;
}
}
return false;
}
public function isFreeze(): bool
{
foreach ($this->getStepsChained() as $step) {
if ($step->isFreezeAfter()) {
return true;
}
}
return false;
}
public function isOnHoldByUser(User $user): bool
{
return $this->getCurrentStep()->isOnHoldByUser($user);
}
public function isUserSubscribedToFinal(User $user): bool
{
return $this->subscriberToFinal->contains($user);
}
public function isUserSubscribedToStep(User $user): bool
{
return $this->subscriberToStep->contains($user);
}
public function prepareStepBeforeTransition(EntityWorkflowStep $step): self
{
$this->transitionningStep = $step;
return $this;
}
public function removeComment(EntityWorkflowComment $comment): self
{
if ($this->comments->removeElement($comment)) {
$comment->setEntityWorkflow(null);
}
return $this;
}
public function removeStep(EntityWorkflowStep $step): self
{
if ($this->steps->removeElement($step)) {
$step->setEntityWorkflow(null);
}
return $this;
}
public function removeSubscriberToFinal(User $user): self
{
$this->subscriberToFinal->removeElement($user);
return $this;
}
public function removeSubscriberToStep(User $user): self
{
$this->subscriberToStep->removeElement($user);
return $this;
}
public function setRelatedEntityClass(string $relatedEntityClass): EntityWorkflow
{
$this->relatedEntityClass = $relatedEntityClass;
return $this;
}
public function setRelatedEntityId(int $relatedEntityId): EntityWorkflow
{
$this->relatedEntityId = $relatedEntityId;
return $this;
}
/**
* Method use by marking store.
*
* @return $this
*/
public function setStep(
string $step,
WorkflowTransitionContextDTO $transitionContextDTO,
string $transition,
\DateTimeImmutable $transitionAt,
?User $byUser = null,
): self {
$previousStep = $this->getCurrentStep();
$previousStep
->setTransitionAfter($transition)
->setTransitionAt($transitionAt)
->setTransitionBy($byUser);
$newStep = new EntityWorkflowStep();
$newStep->setCurrentStep($step);
foreach ($transitionContextDTO->futureCcUsers as $user) {
$newStep->addCcUser($user);
}
foreach ($transitionContextDTO->futureDestUsers as $user) {
$newStep->addDestUser($user);
}
if (null !== $transitionContextDTO->futureUserSignature) {
$newStep->addDestUser($transitionContextDTO->futureUserSignature);
}
foreach ($transitionContextDTO->futureDestEmails as $email) {
$newStep->addDestEmail($email);
}
if (null !== $transitionContextDTO->futureUserSignature) {
new EntityWorkflowStepSignature($newStep, $transitionContextDTO->futureUserSignature);
} else {
foreach ($transitionContextDTO->futurePersonSignatures as $personSignature) {
new EntityWorkflowStepSignature($newStep, $personSignature);
}
}
// copy the freeze
if ($this->isFreeze()) {
$newStep->setFreezeAfter(true);
}
$this->addStep($newStep);
return $this;
}
public function setWorkflowName(string $workflowName): EntityWorkflow
{
$this->workflowName = $workflowName;
return $this;
}
private function getPreviousStepIfAny(): ?EntityWorkflowStep
{
if (1 === \count($this->steps)) {
return null;
}
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;
}
}