Le composant workfow

Ce composant, introduit dans Symfony 3.2, est extrêmement puissant. Il permet de créer des state machines (donc un seul état possible à chaque fois) ou des workflows (plusieurs états possibles à chaque fois). Cela peut être très utile lorsque vous désirez gérer une suite d’états dépendant d’un ou plusieurs autres états avec des règles précises de passage d’un état à un autre.

Il a été utilisé dans mon entreprise pour gérer le traitement de fichiers zip: dézippage, parcours de son contenu, lecture du xml contenu dedans, enregistrement en bdd…

Il est possible de s’en servir pour gérer des règles de gestion d’articles selon les rôles admin/journaliste, comme le montre le code de ce repo: https://github.com/lyrixx/SFLive-Paris2016-Workflow

Mots clef à retenir:

  • Transition: passage d’un état à un autre, représenté par des flèches ou des rectangles
  • Places: état, représenté par un rond

 

Les principales classes du composant workflow sont:

  • Transition: représente une Transition
  • Definition: contient les Places (string), les Transitions et l’état initial
  • Marking: contient l’état actuel / les états actuels (il s’agit d’un array de type [‘accepted’ => 1])
  • MarkingStore: est l’interface entre le workflow et le sujet (= l’objet)
  • Workflow: nous aide à décider quelles actions sont autorisées pour un sujet
  • Registry: stocke et fournit un accès aux différents workflows

 

Workflow vs Statemachine

La classe StateMachine étend la classe Workflow.

Dans une stateMachine, il n’y a toujours qu’un et un seul état possible:

Ici, soit on passe de in_review à changed_needed, soit on passe de in_review à published, mais on ne peut à la fois être dans l’état changes_needed et dans l’état published.

Dans un workflow, plusieurs états en parallèle sont possibles:

Ici, selon les transitions appliquées, on est dans un ou deux états parallèles:

  • wait_for_journalist & wait_for_spellchecker,
  • approved_by_journalist & wait_for_spellchecker,
  • ou published.

Et détail croustillant, on ne peut passer à l’état published que si les deux états approved_by_journalist et approved_by_spellchecker sont atteints. Vous commencez à voir l’intérêt du composant workflow autre que juste s’assurer que des étapes s’exécutent dans le bon ordre?

Définissons notre workflow

Tout d’abord, nous définissons un workflow de type stateMachine:

framework:
    workflows:
        post:
            type: state_machine
            audit_trail: true
            supports: AppBundle\Entity\Post
            marking_store:
                type: single_state
                arguments: [status]
            type: state_machine
            places: [new, accepted, published]
            transitions:
                accept:
                    from: new
                    to: accepted
                reject:
                    from: accepted
                    to: new
                publish:
                    from: accepted
                    to: published
                unpublish:
                    from: published
                    to: accepted

post: il s’agit du nom du workflow. Il aura son importance pour attraper les events dispatchés par le composant.
type: state_machine ou workflow?
audit_trail: permet de logger l’entrée et la sortie d’une place, l’exécution d’une transition.
support: l’entité pour laquelle nous allons suivre l’évolution du statut
marking_store.type: est-ce de type single_state ou multiple_state?
marking_store.arguments: par défaut, cette valeur est marking, ce qui oblige à définir un attribut marking dans l’entité. Ici, nous décidons que le statut aura la propriété status en base et dans notre entité
places: les états possibles que notre entité va avoir
transitions: les transitions pour passer d’un état à un autre

Rajoutons l’attribut $status à notre entité

<?php

namespace AppBundle\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass="AppBundle\Repository\PostRepository")
 * @ORM\Table(name="post")
 */
class Post
{
    /**
     * @var int
     *
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(type="text")
     * @Assert\NotBlank(message="post.blank_content")
     * @Assert\Length(min=10, minMessage="post.too_short_content")
     */
    private $content;

    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime")
     * @Assert\DateTime
     */
    private $publishedAt;

    /**
     * @var string
     *
     * @ORM\Column(type="string", nullable=true)
     */
    private $status;

    public function getId()
    {
        return $this->id;
    }

    public function getContent()
    {
        return $this->content;
    }

    /**
     * @param string $content
     */
    public function setContent($content)
    {
        $this->content = $content;
    }

    public function getPublishedAt()
    {
        return $this->publishedAt;
    }

    public function setPublishedAt(\DateTime $publishedAt = null)
    {
        $this->publishedAt = $publishedAt;
    }

    /**
     * @return string
     */
    public function getStatus()
    {
        return $this->status;
    }

    /**
     * @param string $status
     */
    public function setStatus($status)
    {
        $this->status = $status;
    }
}

 

Les filtres Twig

Twig possède quatre filtres qui permettent d’interagir avec le composant workflow:

  • workflow_can()Retourne true si l’objet donné peut faire la transition
  • workflow_transitions()Retourne un tableau avec toutes les transitions possibles pour l’objet
  • workflow_marked_places()Retourne un tableau avec les états courants de l’objet
  • workflow_has_marked_place()Retourne true si l’objet a bien l’état donné
<h3>Actions</h3>
{% if workflow_can(post, 'publish') %}
    <a href="...">Publish article</a>
{% endif %}
{% if workflow_can(post, 'accept') %}
    <a href="...">Accept article</a>
{% endif %}
{% if workflow_can(post, 'reject') %}
    <a href="...">Reject article</a>
{% endif %}

{# Or loop through the enabled transitions #}
{% for transition in workflow_transitions(post) %}
    <a href="...">{{ transition.name }}</a>
{% else %}
    No actions available.
{% endfor %}

{# Check if the object is in some specific place #}
{% if workflow_has_marked_place(post, 'accept') %}
    <p>This post is ready for being accepted.</p>
{% endif %}

{# Check if some place has been marked on the object #}
{% if 'waiting_some_approval' in workflow_marked_places(post) %}
    <span class="label">PENDING</span>
{% endif %}

 

Dans le cas d’un crud, nous pouvons mettre ceci dans la vue:

    <div class="section actions">
        <form action="{{ url('admin_post_edit_workflow', { id: post.id }) }}" method="post">
            <div class="btn-toolbar" role="toolbar">
                {% for transition in workflow_transitions(post) %}
                    <button value="{{ transition.name }}"
                            name="transition" type="submit"
                            class="btn btn-primary btn-lg"
                    >
                        {{ transition.name|title }}
                    </button>
                {% endfor %}
            </div>
            <input type="hidden" name="token" value="{{ csrf_token('') }}">
        </form>
    </div>

Au départ, nous avons le bouton Accept:

En cliquant dessus, nous voyons apparaître les boutons reject et publish:

 

En cliquant sur publish, nous voyons apparaître le bouton unpublish

L’action du contrôleur pour appliquer les transitions

Mais pour que le comportement ci dessus fonctionne, il faut bien sûr appliquer la transition à chaque fois dans le contrôleur:

    /**
     * @Route("/{id}/workflow", requirements={"id": "\d+"}, name="admin_post_edit_workflow")
     * @Method("POST")
     * @param Post $post
     * @param Request $request
     *
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     */
    public function editWorkflowAction(Post $post, Request $request)
    {
        if (!$this->isCsrfTokenValid('', $request->request->get('token'))) {
            throw $this->createAccessDeniedException('noooope!');
        }

        try {
            $this->get('state_machine.post')->apply($post, $request->request->get('transition'));
        } catch (LogicException $e) {
            $this->addFlash('warning', 'Oups, this transition is not available anymore.');

            return $this->redirectToRoute('admin_post_edit', ['id' => $post->getId()]);
        }

        $this->get('doctrine.orm.default_entity_manager')->flush();

        $this->addFlash('success', 'post.updated_successfully');

        return $this->redirectToRoute('admin_post_edit', ['id' => $post->getId()]);
    }

Et c’est tout! A chaque fois que l’on appliquera une transition, le nom du nouveau statut sera enregistré en base sous forme de string.

Les events

Ah mais ce n’est pas tout. S’il ne permettait que faire cela, le composant workflow serait un peu limité. Mais à chaque étape, de nombreux events sont dispatchés!

workflow.guard

Valide si une transition est autorisée

Les trois events dispatchés sont:

  • workflow.guard
  • workflow.[workflow name].guard
  • workflow.[workflow name].guard.[transition name]

[su_spacer]

workflow.leave

L’objet est sur le point de quitter un état (une place)

Les trois events dispatchés sont:

  • workflow.leave
  • workflow.[workflow name].leave
  • workflow.[workflow name].leave.[place name]

[su_spacer]

workflow.transition

L’objet est en train d’effectuer sa transition

Les trois events dispatchés sont:

  • workflow.transition
  • workflow.[workflow name].transition
  • workflow.[workflow name].transition.[transition name]

[su_spacer]

workflow.enter

L’objet est entrée dans un nouvel état (place). C’est le premier event où l’objet est marqué comme étant à une nouvelle place

Les trois events dispatchés sont:

  • workflow.enter
  • workflow.[workflow name].enter
  • workflow.[workflow name].enter.[place name]

[su_spacer]

workflow.entered

Similaire à workflow.enter, excepté que le champ status (le marking store) est mis à jour avant cette event (en faisant une bonne place pour flusher les data dans Doctrine).

Les trois events dispatchés sont:

  • workflow.entered
  • workflow.[workflow name].entered
  • workflow.[workflow name].entered.[place name]

[su_spacer]

workflow.announce

Lancé à chaque transition qui est à présent accessible pour l’objet

Les trois events dispatchés sont:

  • workflow.announce
  • workflow.[workflow name].announce
  • workflow.[workflow name].announce.[transition name]

[su_spacer]

Notre premier event

Créons un subscriber qui va écouter la transition publish et mettre à jour la date de publication:

<?php

namespace AppBundle\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\Event;

class PostWorkflowSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        return [
            'workflow.post.transition.publish' => 'setPublishedAt',
        ];
    }

    /**
     * @param Event $event
     */
    public function setPublishedAt(Event $event)
    {
        $post = $event->getSubject();

        if (!$post->getPublishedAt()) {
            $post->setPublishedAt(new \DateTime());
        }
    }
}

 

N’oublions pas de l’enregistrer dans la classe services.yml:

app.workflow.listener.post:
    class: AppBundle\EventListener\PostWorkflowSubscriber
    tags:
        - { name: kernel.event_subscriber }

Il n’y a rien d’autre à faire.

[su_spacer]

Et si je veux ne permettre qu’aux super admins de publier ou de dépublier?

Très bonne question, et la réponse est extrêmement simple:

1) soit en écoutant les events workflow.post.guard.publish et workflow.post.guard.unpublish en injectant l’authorization_checker et en appelant la méthode isGranted

<?php

namespace AppBundle\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\Event\GuardEvent;

class PostWorkflowSubscriber implements EventSubscriberInterface
{
    /**
     * @var AuthorizationCheckerInterface
     */
    private $authorizationChecker;

    public function __construct(AuthorizationCheckerInterface $authorizationChecker)
    {
        $this->authorizationChecker = $authorizationChecker;
    }

    public static function getSubscribedEvents()
    {
        return [
            'workflow.post.transition.publish' => 'setPublishedAt',
            'workflow.post.guard.publish' => 'onPublishTransition',
            'workflow.post.guard.unpublish' => 'onUnpublishTransition',
        ];
    }

    /**
     * @param Event $event
     */
    public function setPublishedAt(Event $event)
    {
        $post = $event->getSubject();

        if (!$post->getPublishedAt()) {
            $post->setPublishedAt(new \DateTime());
        }
    }

    public function onPublishTransition(GuardEvent $event)
    {
        if (!$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN', $event->getSubject())) {
            $event->setBlocked(true);
        }
    }

    public function onUnpublishTransition(GuardEvent $event)
    {
        if (!$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN', $event->getSubject())) {
            $event->setBlocked(true);
        }
    }
}

 

2) soit en faisant beaucoup plus simple que cela avec l’expression language:

framework:
    workflows:
        post:
            type: state_machine
            audit_trail: true
            supports: AppBundle\Entity\Post
            marking_store:
                type: single_state
                arguments: [status]
            type: state_machine
            places: [new, accepted, published]
            transitions:
                accept:
                    from: new
                    to: accepted
                reject:
                    from: accepted
                    to: new
                publish:
                    from: accepted
                    to: published
                    guard: has_role('ROLE_SUPER_ADMIN')
                unpublish:
                    from: published
                    to: accepted
                    guard: has_role('ROLE_SUPER_ADMIN')

Elle est pas belle, la vie ? 😉

[su_spacer]

Notre propre service de mise à jour de statut

Eh oui, jusque là, nous avons laissé le composant workflow mettre à jour le statut, mais si nous décidions qu’il devait être mis à jour selon des règles précises?

Nous allons créer un service permettant de mettre à jour le statut sous forme de bit (pour permettre le cumul des droits). Ainsi, IS_NEW correspondra au bit 1 (0001), ACCEPTED au bit 2 (0010), et PUBLISHED au bit 4 (0100).

<?php

namespace AppBundle\Workflow;

use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface;

class BitMarkingStore implements MarkingStoreInterface
{
    const IS_NEW = 1;
    const ACCEPTED = 2;
    const PUBLISHED = 4;

    public function getMarking($post)
    {
        $places = [];
        $bitField = $post->getStatus();

        if ($bitField & self::IS_NEW) {
            $places['new'] = 1;
        }

        if ($bitField & self::ACCEPTED) {
            $places['accepted'] = 1;
        }

        if ($bitField & self::PUBLISHED) {
            $places['published'] = 1;
        }

        return new Marking($places);
    }

    public function setMarking($post, Marking $marking)
    {
        $bitField = 0;

        if ($marking->has('new')) {
            $bitField |= self::IS_NEW;
        }

        if ($marking->has('accepted')) {
            $bitField |= self::ACCEPTED;
        }

        if ($marking->has('published')) {
            $bitField |= self::PUBLISHED;
        }

        $post->setStatus($bitField);
    }
}

 

Déclarons le service:

app.workflow.marking_store.bit:
    class: AppBundle\Workflow\BitMarkingStore

 

Modifions le workflow:

framework:
    workflows:
        post:
            type: state_machine
            audit_trail: true
            supports: AppBundle\Entity\Post
            marking_store:
                #type: single_state
                #arguments: [status]
                service: app.workflow.marking_store.bit
            type: state_machine
            places: [new, accepted, published]
            transitions:
                accept:
                    from: new
                    to: accepted
                reject:
                    from: accepted
                    to: new
                publish:
                    from: accepted
                    to: published
                    guard: has_role('ROLE_SUPER_ADMIN')
                unpublish:
                    from: published
                    to: accepted
                    guard: has_role('ROLE_SUPER_ADMIN')

 

Et modifions notre entité:

/**
     * @var integer
     *
     * @ORM\Column(type="integer", nullable=true)
     */
    private $status;

    /**
     * @return integer
     */
    public function getStatus()
    {
        return $this->status;
    }

    /**
     * @param integer $status
     */
    public function setStatus($status)
    {
        $this->status = $status;
    }

Bon, là, je vous vois venir, le fait que j’ai utilisé des bits ne sert pas à grand chose et vous avez raison. En fait, c’est par prévoyance. C’est à dire que si je créé à un moment le statut 3, que va-t-il se passer? Bingo! J’aurai comme places possibles celle du statut 1 et celle du statut 2 car 0011, c’est l’addition de 0001 et 0010.

[su_spacer]

Modification de la requête de récupération des articles

Nous ne voulons récupérer que les articles qui sont publiés, donc qui ont le champ status avec un bit 4. Pour vérifier cela, nous allons faire appel à la fonction BIT_AND qui fait un & et qui regarde ce qu’il y a de commun entre deux nombres exprimés sous forme de bits.

Par exemple, 6 & 3 donnera 2: 0110 & 0011.
Ce qu’il y a en commun est 0010, soit 2.

Nous allons comparer le statut de chaque article avec 4 pour vérifier que le & donne un nombre supérieur à 0.

4 & 1 donne 0.
4 & 2 donne 0.
4 & 4 donne 4.

La requête devient donc:

$query = $this->getEntityManager()
            ->createQuery('
                SELECT p
                FROM AppBundle:Post p
                WHERE p.publishedAt <= :now
                AND BIT_AND(p.status, :status) > 0
                ORDER BY p.publishedAt DESC
            ')
            ->setParameter('now', new \DateTime())
            ->setParameter('status', 4)
        ;

 

Le mot de la fin

Ce composant est vraiment puissant et regorge de possibilités.

Attention, il y a quelques spécificités:

  • Si on utilise un service pour gérer le statut, on ne peut plus mettre type: single_state ou type: multiple_state dans la config (c’est pour ça que je l’ai commenté)
  • Si on utilise un type worflow plutôt qu’un type state_machine, il faudra appeler le service $this->get(‘workflow.nom_du_workflow’) plutôt que $this->get(‘state_machine.nom_du_workflow’)
  • Il est bien sûr possible, dans un workflow, de définir plusieurs places dans un from du style:
    publish:
       from: [accepted, in_review]
       to: published

     

Rédigé par

2 comments

  1. Encore merci Jean Pierre pour cette présentation de l’outils workflow.
    C’est exactement ce qu’il me faut pour un projet en cours.

    Bonne continuation

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.