Commit 514cca56 authored by Anthony Gautreau's avatar Anthony Gautreau
Browse files

Version initiale de unicaen/workflow-bundle et @unicaen/workflow

parents
Loading
Loading
Loading
Loading

.php-cs-fixer.dist.php

0 → 100644
+13 −0
Changes for .php-cs-fixer.dist.php: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

$finder = (new PhpCsFixer\Finder())
    ->in(__DIR__.'/src')
    ->exclude('vendor')
;

return (new PhpCsFixer\Config())
    ->setRules([
        '@Symfony' => true,
    ])
    ->setFinder($finder)
;

.version

0 → 100644
+1 −0
Changes for .version: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
1.0.0

README.md

0 → 100644
+269 −0
Changes for README.md: 269 added lines, 0 removed lines.
Original line number Diff line number Diff line
# unicaen/workflow-bundle

Circuits Symfony Workflow stockés en base, avec tâches, historique et mails de transition. Le bundle expose une API par
API Platform, facultatif, et laisse à l'application le soin de préciser les droits, par ses voters ou par
unicaen/privilege-bundle.

## Installation

```bash
composer require unicaen/workflow-bundle
```

Sans Symfony Flex, déclarer le bundle dans `config/bundles.php` :

```php
Unicaen\Workflow\UnicaenWorkflowBundle::class => ['all' => true],
```

Les entités sont mappées par `doctrine.orm.auto_mapping`. Les tables `unicaen_workflow_*` se créent par une migration de
l'application (`doctrine:migrations:diff`). Dépendances : Doctrine ORM, Security, Workflow, Mailer. API Platform est
facultatif.

```yaml
# config/packages/unicaen_workflow.yaml, tout est facultatif
unicaen_workflow:
    cache_pool: cache.app            # définitions construites, pool partagé entre serveurs s'il y en a plusieurs
    storage_directory: '%kernel.project_dir%/var/workflow_files'
    security_attribute: ROLE_ADMIN   # rôle ou attribut de voter qui reçoit les droits WORKFLOW_*, null pour les laisser à l'application
    properties:                      # propriétés de classes qu'on ne peut pas annoter, champ: modifiable
        Unicaen\Utilisateur\Entity\User: { email: false, displayName: false }
```

## Fonctionnement

Un **circuit** relie des **places** (les étapes de l'interface) par des **transitions**. Un **sujet** est l'entité qui
le parcourt, son **marking** dit quelles places il occupe. Une **tâche** est à réaliser sur une place avant de la quitter.

Le moteur est le composant Symfony, le bundle agit autour :

1. Un sujet persisté avec un `workflowCode` est posé sur la place initiale (`SubjectInitializer`).
2. `DynamicWorkflowRegistry` construit le workflow du circuit depuis la base et garde sa définition en cache.
3. `WorkflowTaskService::submit` fait contrôler la saisie par le type de tâche, puis enregistre la réponse, et une
   révision si la tâche est terminée.
4. `WorkflowSubjectService::apply` lance la transition. Sur `workflow.guard`, `GuardListener` évalue le guard et
   `TaskListener` bloque tant qu'une tâche obligatoire n'est pas terminée.
5. Sur `workflow.completed`, `TransitionLogListener` journalise, `TaskListener` rouvre les tâches si `resetTasks` et
   `TransitionMailListener` prépare le mail.
6. Le flush enregistre le tout, le mail part en fin de requête s'il a réussi.

Modifier un circuit purge son cache (`CacheInvalidationListener`) et protège ses sujets (`CircuitChangeListener`).

Trois contrôles décident qui agit : `WorkflowVoter` pour le sujet, le `guard` pour la transition, le `performer` pour la
tâche.

## Modèle

| Entité | Table | Rôle |
| --- | --- | --- |
| `WorkflowDefinition` | `unicaen_workflow_definition` | circuit : `code` unique et fixé à la création, `label`, `type` (`workflow` ou `state_machine`), `supports` (classe ou interface du sujet) |
| `WorkflowPlace` | `unicaen_workflow_place` | place : `name` unique dans le circuit, `label`, `initial`, `position` |
| `WorkflowTransition` | `unicaen_workflow_transition` | transition : `name`, `label`, `guard`, `position`, `requireComment`, `resetTasks`, `mail`, places de départ et d'arrivée |
| `WorkflowTask` | `unicaen_workflow_task` | tâche d'une place : `name`, `label`, `type`, `config`, `required`, `position`, `performer` |
| `WorkflowTaskResponse` | `unicaen_workflow_task_response` | réponse courante d'un sujet à une tâche, brouillon ou terminée |
| `WorkflowTaskSubmission` | `unicaen_workflow_task_submission` | révision : la saisie d'une tâche à chaque fois qu'elle est terminée |
| `WorkflowTransitionLog` | `unicaen_workflow_transition_log` | journal des transitions : places quittées et atteintes, utilisateur, commentaire |

Réponses, révisions et journal désignent le sujet par sa classe et son identifiant, sans clé étrangère : supprimer un
sujet les laisse en place, à l'application d'en décider.

Plusieurs circuits peuvent viser la même classe, c'est le sujet qui porte le code du sien. Une machine à états a au plus
une place initiale, et une seule arrivée par transition ; sans place initiale, les sujets déjà placés avancent, un
nouveau est refusé (`NoInitialPlaceException`). Comme `framework.workflows`, une transition de machine à états à
plusieurs départs est éclatée en un arc par départ.

### Circuits en cours

Renommer une place réécrit le marking des sujets du circuit dans le même flush, l'historique garde l'ancien nom.
Supprimer une place occupée, ou changer le type, la classe ou le code d'un circuit qui a des sujets, lève
`WorkflowInUseException` (409). Les sujets sont retrouvés parmi les entités qui implémentent `WorkflowSubjectInterface`
et ont un champ `workflowCode`, comme le fournit `WorkflowSubjectTrait`.

## Sujet

```php
class Dossier implements WorkflowSubjectInterface
{
    use WorkflowSubjectTrait;
}

$dossier->setWorkflowCode('INSCRIPTION');
$entityManager->persist($dossier);          // posé sur la place initiale
$entityManager->flush();

$subjectService->getState($dossier);        // places, transitions possibles et blocages, tâches, résumé du circuit
$taskService->submit($dossier, $taskId, ['nom' => 'Dupont'], [], final: true);
$subjectService->apply($dossier, 'deposer', 'Premier dépôt');
$entityManager->flush();                    // réponse et révision, marking, journal
$subjectService->getHistory($dossier);      // transitions et révisions, dans l'ordre
```

`DynamicWorkflowRegistry::get($sujet)` renvoie le `WorkflowInterface` Symfony. Les workflows s'appellent
`dynamic.<code>` : les événements habituels sont émis, par exemple `workflow.dynamic.INSCRIPTION.completed.deposer`.

Le trait n'apporte pas de verrou : ajoutez un champ `#[ORM\Version]` au sujet. Sans lui, deux transitions simultanées
s'écrasent le marking sur un circuit parallèle. Avec lui, la seconde répond 409.

Une ressource qui déclare des groupes de sérialisation ajoute ceux du trait : `workflow_subject:read` pour `workflowCode`
et `marking`, `workflow_subject:write` pour `workflowCode`. `marking` n'est jamais modifiable par l'API. Réservez
`workflow_subject:write` à la création : changer le code d'un sujet déjà placé le laisserait sur des places inconnues de
son nouveau circuit.

### Guards et performer

Le `guard` d'une transition et le `performer` d'une tâche sont des expressions évaluées avec les variables des guards
Symfony : `subject`, `user`, `token`, `role_names`, `is_granted()`, `is_valid()`... Elles sont contrôlées à
l'enregistrement (`ValidExpression`) et ne peuvent appeler que des accesseurs (`get*`, `is*`, `has*`) : modifier un
circuit ne permet pas de modifier un sujet. Une expression enregistrée hors validation et invalide, ou qui échoue à
l'exécution, bloque sans faire échouer la requête, et l'erreur est journalisée. Sur une relation facultative, écrire
`subject.getCompte()?.getId()`.

Un guard vide ouvre la transition à qui peut agir sur le sujet. Le texte du guard n'est jamais renvoyé : le blocage dit
seulement que la condition n'est pas remplie, et le résumé du circuit cite les attributs de ses `is_granted()` pour dire
qui doit agir.

### Tâches

Tant qu'une tâche `required` n'est pas terminée, les transitions qui quittent sa place sont bloquées (code
`workflow_task_incomplete`).

| Type | `config` | Saisie |
| --- | --- | --- |
| `form` | `{description?, fields: [{name, label, type, required?, multiple?, options?, help?, property?}]}`, `type` parmi `text`, `textarea`, `number`, `date`, `boolean`, `checkbox`, `select` | `{champ: valeur}` |
| `upload` | `{description?, accept?: [types MIME], maxSize?, min?, max?}`, `min` vaut 1 par défaut | fichiers sous `files`, `keep` : identifiants des fichiers déjà déposés à garder |
| `confirmation` | `{text}` | `{accepted: true}` |

Une saisie se fait en brouillon ou terminée. Terminer une tâche crée une révision ; les fichiers d'une révision ne sont
plus supprimés, même remplacés ensuite. Un fichier déposé dont la réponse n'est pas enregistrée est supprimé en fin de
requête. Une transition `requireComment` exige un commentaire (`CommentRequiredException`), une transition `resetTasks`
repasse en brouillon les tâches des places d'arrivée, pour un renvoi en correction.

Un type s'ajoute en implémentant `TaskTypeInterface`, enregistré par l'autoconfiguration. Les fichiers passent par
`TaskFileStorageInterface`, sur disque local par défaut :

```yaml
services:
    Unicaen\Workflow\Storage\TaskFileStorageInterface: '@App\Storage\S3TaskFileStorage'
```

### Propriétés du sujet

Un champ simple du sujet (texte, nombre, date, booléen) est utilisable dans un circuit s'il porte
`#[WorkflowProperty]`, en lecture seule par défaut, ou s'il est déclaré dans l'option `properties`. Une relation vers une
entité porte elle aussi l'attribut pour ouvrir les champs de cette entité, désignés par `relation.champ`, sur un niveau.

Un champ de formulaire lié par `property` en est prérempli et l'y écrit quand la tâche est terminée, après contrôle par
les contraintes de l'entité. `SubjectProperties::describe($classe)` liste les propriétés proposées.

### Mail d'une transition

```json
{"to": ["compte.email", "scolarite@exemple.fr"], "subject": "Dossier de {{ nom }}", "body": "Bonjour,\n..."}
```

Un destinataire est une adresse ou une propriété du sujet, `{{ propriété }}` est remplacé dans le sujet et le corps. Le
mail est envoyé en fin de requête ou de commande, seulement si la transition a été enregistrée, par
`TransitionMailerInterface`. Par défaut Symfony Mailer, l'expéditeur venant de `framework.mailer.headers.from` :

```yaml
services:
    Unicaen\Workflow\Mail\TransitionMailerInterface: '@App\Mail\WorkflowTransitionMailer'
```

## Sécurité

`WorkflowVoter` définit les attributs du bundle et ne les accorde qu'à `security_attribute`, un rôle ou tout attribut voté par l'application :

| Attribut | Objet du vote | Sert à |
| --- | --- | --- |
| `WORKFLOW_MANAGE` | aucun | gérer les circuits |
| `WORKFLOW_VIEW` | le sujet | voir son état et son historique, un refus répond 404 |
| `WORKFLOW_ACT` | le sujet | appliquer une transition, réaliser une tâche, sous réserve des guards et performers |
| `WORKFLOW_OWNER` | le sujet | être le propriétaire du sujet, pour les guards : `is_granted('WORKFLOW_OWNER', subject)` |

L'application précise les siens par un voter sur les mêmes attributs, la stratégie affirmative retenant le premier
accord. `WORKFLOW_OWNER` n'est jamais accordé par le bundle : sans ce voter, la condition « propriétaire » de l'éditeur
reste fausse. Par exemple, le propriétaire voit son dossier et agit dessus :

```php
final class DossierWorkflowVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return $subject instanceof Dossier && \in_array($attribute, [WorkflowVoter::VIEW, WorkflowVoter::ACT, WorkflowVoter::OWNER], true);
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
    {
        return null !== $subject->getCompte() && $subject->getCompte() === $token->getUser();
    }
}
```

### Avec unicaen/privilege-bundle

Les droits `WORKFLOW_*` viennent alors des privilèges de la route :

```yaml
# config/packages/unicaen_workflow.yaml
unicaen_workflow:
    security_attribute: UNICAEN_PRIVILEGE

# config/packages/unicaen_privilege.yaml
unicaen_privilege:
    routes:
        entities:                       # entité => préfixe des privilèges
            workflow_definition: workflow
            workflow_place: workflow
            workflow_transition: workflow
            workflow_task: workflow
            dossier: workflow           # chaque classe de sujet
```

| Opération | Privilège |
| --- | --- |
| Lire les circuits, l'état, l'historique et les fichiers d'un sujet | `workflow_voir` |
| Créer un élément de circuit, appliquer une transition, réaliser une tâche | `workflow_creer` |
| Modifier un élément de circuit | `workflow_modifier` |
| Supprimer un élément de circuit | `workflow_supprimer` |

Un référentiel prêt à copier est fourni dans [docs/data/25_privilege_workflow.yaml](../docs/data/25_privilege_workflow.yaml),
avec la catégorie `unicaen_workflow` à déclarer. Une fois les privilèges créés, `unicaen:privilege:sync-routes --assign`
relie chaque route au sien.

## API Platform

Si API Platform est installé :

- ressources `/workflow_definitions`, `/workflow_places`, `/workflow_transitions`, `/workflow_tasks` (sous
  `WORKFLOW_MANAGE`), et `/workflow_definitions/{id}/subject_properties` ;
- sur toute ressource qui implémente `WorkflowSubjectInterface`, sous l'URI de son `Get` :

| URI | Méthode | Corps et réponse |
| --- | --- | --- |
| `{sujet}/workflow` | GET | `{workflowCode, definition, places, transitions, tasks}` |
| `{sujet}/workflow/transitions` | POST | `{transition, comment?}`, nouvel état ; 409 et `blockers` si la transition est bloquée |
| `{sujet}/workflow/tasks/{task}` | POST | JSON ou JSON-LD `{data, final}`, ou multipart `data` (JSON), `final`, `files[]` ; nouvel état, 422 si la saisie est refusée, 400 si le corps est illisible |
| `{sujet}/workflow/files/{file}` | GET | contenu du fichier, y compris ceux des révisions |
| `{sujet}/workflow/history` | GET | `{entries: [...]}`, transitions et révisions |

Les opérations s'appellent `<sujet>_workflow`, `_workflow_apply`, `_workflow_task`, `_workflow_file`,
`_workflow_history`, par exemple `dossier_workflow_apply`. Sans API Platform, les services `WorkflowSubjectService` et
`WorkflowTaskService` donnent les mêmes opérations.

## Cache et console

La `Definition` d'un circuit est construite à sa première utilisation, gardée dans `cache_pool` et purgée dès qu'un
flush modifie le circuit. Sur plusieurs serveurs, `cache_pool` doit être partagé.

```bash
php bin/console workflow:dynamic:dump INSCRIPTION --dump-format=mermaid
```

## Limites

- Identifiant composite : les opérations du workflow ne sont pas ajoutées à la ressource.
- Les contrôles d'un circuit en cours chargent tous ses sujets en mémoire, à lire par lots au-delà de quelques milliers.
- Une transition appliquée dans un worker Messenger n'envoie pas son mail.
- Un type de tâche ajouté par l'application n'a pas d'écran : `@unicaen/workflow` ne connaît que `form`, `upload` et
  `confirmation`.

composer.json

0 → 100644
+56 −0
Changes for composer.json: 56 added lines, 0 removed lines.
Original line number Diff line number Diff line
{
    "name": "unicaen/workflow-bundle",
    "type": "symfony-bundle",
    "description": "Bundle Unicaen Workflow : circuits Symfony Workflow stockés en base, tâches, historique et mails de transition",
    "keywords": ["symfony", "workflow", "state-machine", "doctrine", "dynamic"],
    "license": "MIT",
    "authors": [
        {"name": "Cédric Bois", "email": "cedric.bois@uca.fr", "role": "Auteur de la version d'origine"},
        {"name": "Anthony Gautreau"}
    ],
    "repositories": [
        {
            "type": "composer",
            "url": "https://packagist.unicaen.fr"
        }
    ],
    "autoload": {
        "psr-4": {
            "Unicaen\\Workflow\\": "src/"
        }
    },
    "require": {
        "php": "^8.2",
        "doctrine/doctrine-bundle": "^2.18 || ^3.0",
        "doctrine/orm": "^3.3",
        "symfony/cache-contracts": "^3.5",
        "symfony/config": "^7.4 || ^8.0",
        "symfony/console": "^7.4 || ^8.0",
        "symfony/dependency-injection": "^7.4 || ^8.0",
        "symfony/expression-language": "^7.4 || ^8.0",
        "symfony/framework-bundle": "^7.4 || ^8.0",
        "symfony/http-foundation": "^7.4 || ^8.0",
        "symfony/http-kernel": "^7.4 || ^8.0",
        "symfony/mailer": "^7.4 || ^8.0",
        "symfony/mime": "^7.4 || ^8.0",
        "symfony/property-access": "^7.4 || ^8.0",
        "symfony/property-info": "^7.4 || ^8.0",
        "symfony/security-bundle": "^7.4 || ^8.0",
        "symfony/serializer": "^7.4 || ^8.0",
        "symfony/validator": "^7.4 || ^8.0",
        "symfony/workflow": "^7.4 || ^8.0"
    },
    "suggest": {
        "api-platform/core": "Pour exposer les circuits et les opérations du workflow de chaque sujet"
    },
    "require-dev": {
        "friendsofphp/php-cs-fixer": "^3.75"
    },
    "extra": {
        "symfony": {
            "bundle": {
                "Unicaen\\Workflow\\UnicaenWorkflowBundle": "all"
            }
        }
    }
}

config/services.yaml

0 → 100644
+14 −0
Changes for config/services.yaml: 14 added lines, 0 removed lines.
Original line number Diff line number Diff line
services:
  _defaults:
    autowire: true
    autoconfigure: true

  Unicaen\Workflow\:
    resource: "../src/*"
    exclude: "../src/{ApiPlatform,Attribute,Entity,Exception,Model,Validator/ValidTaskConfig.php,Validator/ValidExpression.php,UnicaenWorkflowBundle.php}"

  Unicaen\Workflow\Storage\TaskFileStorageInterface:
    alias: Unicaen\Workflow\Storage\LocalTaskFileStorage

  Unicaen\Workflow\Mail\TransitionMailerInterface:
    alias: Unicaen\Workflow\Mail\SymfonyTransitionMailer
Loading