Aller au contenu

Les statuts et transitions dans Forge (forge-mvc-workflow)

Ce document explique ce que fait l'opt-in forge-mvc-workflow, ce qu'il expose, et comment on s'en sert.

Module extrait

Le workflow a été extrait du cœur vers le paquet forge-mvc-workflow ; le cœur Forge n'en dépend pas.

forge-mvc-workflow décrit une machine à états applicative : des statuts (brouillon, publié, archivé), des transitions autorisées entre eux, et des badges pour les afficher.

Il ne stocke rien lui-même : l'application garde le statut courant sur son entité ; l'opt-in dit quelles transitions sont permises.

1. Rôle du module

Beaucoup d'entités ont un cycle de vie : un article passe de brouillon à publié, puis archivé.

L'opt-in modélise ce cycle : on déclare les statuts et les transitions autorisées, puis on vérifie qu'un changement est permis avant de l'appliquer.

Il fournit aussi des helpers Jinja pour afficher un statut sous forme de badge coloré, sans logique dans le template.

2. Installation

Prérequis : activez le venv du projet

Quelle que soit la source, installez dans le venv du projet :

source .venv/bin/activate

Lancé hors d'un venv, pip vise le Python système (Debian 12+, Ubuntu 23.04+),
protégé par PEP 668. Il refuse alors d'installer, pour ne pas écraser les paquets
gérés par apt, et affiche externally-managed-environment.
Le venv de projet créé par forge new n'a pas ce verrou.

Installer le paquet

A. Depuis PyPI (stable)

La dernière version publiée :

pip install --pre forge-mvc-workflow

B. Depuis Git (avant-garde)

Cœur puis opt-in depuis git, dans le venv du projet (l'opt-in trouve le cœur git déjà en place, sans version publiée sur PyPI) :

pip install "git+https://github.com/caucrogeGit/Forge.git@main"
pip install "git+https://github.com/caucrogeGit/Forge.git@main#subdirectory=packages/forge-mvc-workflow"
3. Mise en service

Installer le paquet ne suffit pas à le rendre opérationnel.
Voici les gestes propres à forge-mvc-workflow, dans l'ordre.

Ils déclinent la procédure canonique, Rendre un opt-in opérationnel : les cinq
points
.

1. L'épingler

forge-mvc-workflow==<version de forge-mvc>

Dans requirements.txt, à la même version ou au même commit que forge-mvc.
Sans cette ligne, l'opt-in n'existe que sur votre machine.

2. L'inscrire

forge opt-in:enable workflow --apply

L'opt-in est inscrit dans optins/registry.py (ADR-061), ce qui le rend visible du
projet.
--apply est obligatoire : sans lui, la commande simule et n'écrit rien.

3. Poser ce dont il a besoin

forge workflow:init
forge migration:apply

forge workflow:init écrit la migration de workflow_history dans mvc/migrations/,
où elle reste relisible avant d'être appliquée (charte §7, ADR-071).
La commande n'ouvre aucune connexion.

Cette table est apparue avec WORKFLOW-HISTORY-001 ; l'opt-in n'en avait aucune avant.
Les transitions et les conditions fonctionnent sans elle, seul l'historique en dépend.

4. Le brancher là où il agit

Il s'importe dans le code qui s'en sert. Il n'y a ni route à monter ni middleware
à poser.

5. Le prouver

make check
forge doctor

Puis un premier usage réel.
Un opt-in installé, inscrit et provisionné qu'aucun code n'appelle n'est pas
opérationnel : il est seulement présent.

4. Désinstallation
forge opt-in:disable workflow
pip uninstall forge-mvc-workflow

opt-in:disable est l'inverse d'enable : il dé-inscrit du registre (le code n'était pas câblé), sans toucher au paquet.
forge opt-in:remove workflow affiche la commande pip uninstall sans l'exécuter.

5. Commandes

Cet opt-in n'expose aucune commande CLI : il s'utilise par import dans le code applicatif (voir l'API publique ci-dessous).

6. Vue d'ensemble rapide
Élément Valeur
Paquet forge-mvc-workflow
Module forge_mvc_workflow
Catégorie Données et modélisation (ADR-055)
Couche opt-in (brique optionnelle)
Dépend de forge-mvc
API publique WorkflowStatus, WorkflowTransition, make_status, make_transition, can_transition, get_available_transitions, apply_transition, TransitionEvent, statuses_from_entity_field, helpers Jinja
Persistance aucune table imposée : l'application stocke le statut courant
Helpers Jinja workflow_status_badge, workflow_status_label, workflow_status_color
Exceptions WorkflowStatusError, WorkflowTransitionError
Décision d'architecture ADR-004 (opt-in officiel)
Installation pip install --pre forge-mvc-workflow
7. Schémas UML

Les deux schémas suivants montrent deux vues complémentaires de l'opt-in.

Le diagramme de classe montre les statuts, les transitions et les helpers.

Le diagramme de séquence montre la vérification d'un changement de statut.

5.1 Diagramme de classe

Le diagramme de classe montre que les transitions relient des statuts, et que les helpers Jinja rendent un statut en badge.

classDiagram
    direction LR

    class WorkflowStatus {
        <<dataclass>>
        +str name
        +str label
        +str color
        +bool is_initial
        +bool is_final
    }

    class WorkflowTransition {
        <<dataclass>>
        +str from_status
        +str to_status
    }

    class status {
        <<module>>
        +make_status(...) WorkflowStatus
        +find_status(...) WorkflowStatus
        +validate_statuses(...)
    }

    class transitions {
        <<module>>
        +make_transition(from, to) WorkflowTransition
        +can_transition(transitions, from, to) bool
        +get_available_transitions(transitions, from) list
        +validate_transitions(...)
    }

    class jinja {
        <<module>>
        +workflow_status_badge(...)
        +workflow_status_label(...)
        +workflow_status_color(...)
    }

    transitions --> WorkflowTransition : produit
    status --> WorkflowStatus : produit
    WorkflowTransition --> WorkflowStatus : relie
    jinja --> WorkflowStatus : affiche

À retenir :

  • un WorkflowStatus porte un nom, un libellé, une couleur, des marqueurs initial/final ;
  • une WorkflowTransition relie un statut de départ à un statut d'arrivée ;
  • can_transition répond oui/non avant d'appliquer un changement ;
  • les helpers Jinja affichent un statut sans logique dans le template.

5.2 Diagramme de séquence

Le diagramme de séquence montre un changement de statut contrôlé.

sequenceDiagram
    participant App as Contrôleur
    participant WF as forge_mvc_workflow
    participant Entity as Entité (statut stocké)

    App->>WF: can_transition(TRANSITIONS, "brouillon", "publie") ?
    alt transition autorisée
        WF-->>App: True
        App->>Entity: met à jour le statut = "publie"
    else transition interdite
        WF-->>App: False
        App-->>App: refuse / message d'erreur
    end
    App->>WF: get_available_transitions(TRANSITIONS, "publie")
    WF-->>App: transitions possibles (pour l'UI)

À retenir :

  • on vérifie avant d'écrire le nouveau statut ;
  • l'application reste responsable de persister le statut ;
  • get_available_transitions alimente les boutons/menus de l'UI ;
  • une transition non déclarée est refusée.
8. API publique
Élément Signature Rôle
make_status make_status(name, label="", color="", is_initial=False, is_final=False) -> WorkflowStatus déclare un statut
make_transition make_transition(from_status, to_status) -> WorkflowTransition déclare une transition
can_transition can_transition(transitions, from_name, to_name) -> bool transition autorisée ?
apply_transition apply_transition(transitions, from_status, to_status, *, before=None, commit=None, after=None, context=None) -> str consulte les conditions enregistrées, puis applique dans un ordre garanti
TransitionEvent from_status, to_status, context ce que reçoivent les points d'accroche
statuses_from_entity_field statuses_from_entity_field(entity, field_name, *, initial=None, final=None) -> list[WorkflowStatus] statuts lus des choices d'un contrat d'entité
statuses_from_choices statuses_from_choices(choices, *, initial=None, final=None) -> list[WorkflowStatus] même conversion, depuis les choix seuls
status_values status_values(statuses) -> list[str] noms des statuts, pour comparer deux sources
EntityStatusError exception champ absent, ou choix inexploitable
get_available_transitions get_available_transitions(transitions, from_name) -> list[WorkflowTransition] transitions possibles depuis un statut
find_status find_status(...) -> WorkflowStatus retrouve un statut par nom
validate_statuses, validate_transitions fonctions valident un jeu de statuts/transitions
WorkflowStatus, WorkflowTransition dataclasses statut et transition
helpers Jinja workflow_status_badge, workflow_status_badge_class, workflow_status_color, workflow_status_label, make_workflow_jinja_helpers affichage
WorkflowStatusError, WorkflowTransitionError exceptions nom invalide, transition invalide
9. Contextes d'utilisation
Besoin Élément
Déclarer le cycle de vie make_status + make_transition
Autoriser un changement can_transition(...)
Appliquer un changement apply_transition(...)
Refuser selon une règle métier lever depuis before
Éviter de déclarer les statuts deux fois statuses_from_entity_field(...)
Proposer les suites possibles get_available_transitions(...)
Valider la configuration validate_statuses / validate_transitions
Afficher un badge workflow_status_badge(...) (Jinja)
9 ter. Prendre les statuts du contrat d'entité

Une application qui gère un cycle de vie déclarait sa liste de statuts deux fois.

Une fois en choices du contrat d'entité, pour que le formulaire propose un choix et que la base accepte la valeur.
Une autre fois en Python, en make_status, pour que le workflow connaisse ses transitions.

Rien ne gardait les deux identiques (WORKFLOW-ENTITY-STATUS-001).
Ajouter un statut au contrat sans toucher au workflow donne un choix que le formulaire propose et que la transition refuse.
Le retirer donne une transition vers un statut que la base n'accepte plus.
Dans les deux cas, la panne n'apparaît qu'à l'usage, et sur un seul chemin.

import json
from forge_mvc_workflow import make_transition, statuses_from_entity_field, validate_transitions

contrat = json.loads(Path("mvc/entities/article.json").read_text(encoding="utf-8"))

STATUSES = statuses_from_entity_field(
    contrat, "statut", initial="draft", final=("archived",)
)
TRANSITIONS = validate_transitions(
    [make_transition("draft", "published"), make_transition("published", "archived")],
    STATUSES,
)

validate_transitions refuse alors toute transition vers un statut que le contrat ne déclare pas, au chargement et non à l'usage.

Le champ est nommé, jamais deviné

Repérer « le champ qui ressemble à un statut » supposerait une convention de nommage que Forge n'impose pas, et se tromperait sur une entité qui en porte deux, un statut de publication et un état de paiement par exemple.

Le début et la fin du cycle se déclarent

Un contrat d'entité dit quelles valeurs sont permises, jamais laquelle commence un cycle ni lesquelles le terminent.

initial et final sont donc explicites, et une valeur absente des choix est refusée : une faute de frappe y produirait sinon un cycle sans début, que rien ne signalerait.

Aucune dépendance vers le moteur d'entités

Un contrat est un dictionnaire JSON dont la forme est documentée.

Le lire ne demande pas d'importer forge-mvc-entities, et ce module ne le fait pas : un projet qui décrit ses entités autrement peut lui passer la même structure.

9 bis. Appliquer une transition

Le paquet savait dire si une transition est permise, jamais l'appliquer.
Chaque application réécrivait le même enchaînement à la main, et rien n'empêchait d'appeler l'action d'après quand celle d'avant avait refusé (WORKFLOW-HOOKS-001).

from forge_mvc_workflow import apply_transition

def publier(article, auteur):
    def verifier(evenement):
        if not article.resume:
            raise ValueError("Un article publié doit avoir un résumé.")

    def ecrire(evenement):
        article.status = evenement.to_status
        enregistrer(article)

    def prevenir(evenement):
        notifier_abonnes(article)

    return apply_transition(
        TRANSITIONS, article.status, "published",
        before=verifier, commit=ecrire, after=prevenir,
        context={"auteur": auteur},
    )

L'ordre est garanti, et chaque étape conditionne la suivante.

Rang Étape Si elle lève
1 Vérification de la transition rien d'autre n'est appelé
2 before ni l'écriture ni after n'ont lieu
3 commit after n'a pas lieu
4 after l'écriture reste faite

Un refus se lève, il ne se rend pas

Un point d'accroche ne rend rien : pour refuser, il lève.

Un booléen de retour obligerait Forge à inventer un message d'erreur à la place de la règle métier, alors que l'exception porte déjà le sien.
Elle remonte telle quelle, sans enveloppe : un message maquillé ferait perdre la cause.

after ne défait rien

Une exception levée après l'écriture ne l'annule pas.

L'avaler cacherait un état déjà changé, ce qui est pire que de la laisser remonter.
Une opération qui doit pouvoir être annulée appartient à une transaction, que l'application ouvre autour de son commit.

Le paquet ne persiste rien

commit est fourni par l'application, seule à savoir où son statut est rangé.

Sans lui, after suit immédiatement before : le paquet n'a alors aucun moyen de savoir si l'écriture a eu lieu, et le dire vaut mieux que de laisser croire à une garantie qui n'existe pas.

10. Exemples d'utilisation

8.1 Déclarer et vérifier

from forge_mvc_workflow import make_status, make_transition, can_transition

STATUSES = [
    make_status("brouillon", "Brouillon", color="gray", is_initial=True),
    make_status("publie", "Publié", color="green"),
    make_status("archive", "Archivé", color="slate", is_final=True),

]
TRANSITIONS = [
    make_transition("brouillon", "publie"),
    make_transition("publie", "archive"),

]

if can_transition(TRANSITIONS, "brouillon", "publie"):
    article["status"] = "publie"      # l'application persiste

8.2 Afficher un badge dans un template

{{ workflow_status_badge(STATUSES, article.status) }}

get_available_transitions(TRANSITIONS, article.status) donne les boutons d'action à proposer.

Aide-mémoire

Déclarer, vérifier, afficher :

  • make_status / make_transition pour le cycle ;
  • can_transition / get_available_transitions pour la logique ;
  • les helpers Jinja pour l'affichage.
11. Persistance et validation

L'opt-in ne crée aucune table : le statut courant est un simple champ de votre entité, que vous mettez à jour vous-même après un can_transition positif.

validate_statuses et validate_transitions détectent les configurations incohérentes (statut inconnu, doublon de transition) au démarrage.

L'opt-in décide, l'application persiste

forge-mvc-workflow répond « cette transition est-elle permise ?
»
; il n'écrit jamais en base.

Vous gardez la main sur le stockage du statut (champ d'entité, migration).

Affichage sans logique dans le template

Les helpers Jinja produisent libellé, couleur et badge à partir d'un nom de statut.

Le template reste déclaratif ; la table des statuts vit dans votre code.

Indépendance du cœur

Le cœur de Forge ne dépend pas de forge-mvc-workflow : la dépendance va de l'opt-in vers le cœur.

12. Historique des transitions

Le paquet appliquait les transitions sans en garder trace (WORKFLOW-HISTORY-001) : on savait dans quel état une entité se trouve, jamais comment elle y est arrivée, ni quand, ni par qui.

C'est pourtant la question qu'on pose à un workflow dès qu'un dossier pose problème. « Qui a validé cette commande, et à quelle date » n'avait aucune réponse, et chaque application réinventait sa table.

from forge_mvc_workflow import history_for, record_transition

record_transition(
    "Commande", commande.id, "validee",
    from_status="brouillon", actor_kind="user", actor_id=utilisateur.id,
    comment="stock vérifié",
)

for entree in history_for("Commande", commande.id):
    entree.to_status, entree.actor_id, entree.created_at

L'enregistrement est explicite, et dans VOTRE transaction

apply_transition n'écrit rien de soi même.

Écrire depuis le paquet imposerait une connexion à un module qui n'en avait pas besoin, et surtout séparerait l'historique de l'écriture qu'il décrit : une transaction annulée laisserait une ligne d'historique pour une transition qui n'a pas eu lieu.

Un acteur absent est une information

Une transition automatique, déclenchée par une tâche de fond, n'a pas d'auteur.

Inventer « system » masquerait la différence, et is_automatic la rend lisible. actor_kind et actor_id vont en revanche de pair : un identifiant sans nature ne désigne personne.

Aucune clé étrangère vers l'entité

Le paquet ne sait pas ce qu'est une entité de l'application, et un historique doit survivre à la suppression de son sujet.

C'est justement quand une commande a été supprimée qu'on veut savoir qui l'avait validée.

La table est décrite dans tables.py (WORKFLOW_HISTORY, WORKFLOW_HISTORY_TABLE), les opérations dans history.py et les règles dans conditions.py.

L'historique est trié par identifiant décroissant et non par date : deux transitions de la même seconde se départageraient sinon au hasard, et l'ordre est ce qu'on vient y lire.

13. Conditions de transition

can_transition répond à une seule question : cette transition est elle déclarée ? (WORKFLOW-CONDITIONS-001)

Elle ne peut pas répondre à « cette commande a t elle au moins une ligne », ni à « ce dossier a t il été relu », qui sont pourtant les vraies conditions d'un passage d'état. L'application les vérifiait donc avant d'appeler, chacune à sa façon, et la règle vivait dans les contrôleurs plutôt que dans le workflow. Deux chemins menant au même état s'oubliaient l'un l'autre, et le second passait sans contrôle.

from forge_mvc_workflow import ensure_conditions, register_condition

def stock_verifie(depuis, vers, contexte):
    if not contexte.get("stock_ok"):
        return "le stock doit être vérifié avant validation"
    return None

register_condition(stock_verifie, to_status="validee")
ensure_conditions("brouillon", "validee", {"stock_ok": True})

Une condition dit POURQUOI elle refuse

Une condition qui rendrait False laisserait l'utilisateur devant « transition impossible », message qui n'indique rien à corriger.

Elle rend donc None pour accepter, ou un motif, qui remonte jusqu'à l'écran.

Une condition qui échoue refuse la transition

Une condition qui lève ne dit pas que la transition est permise, elle ne dit rien.

Traiter ce silence comme une autorisation est particulièrement coûteux ici : le jour où le service qu'elle interroge tombe, toutes les transitions passeraient.

Les conditions s'ajoutent, elles ne se remplacent pas

Toutes doivent accepter, et le premier refus arrête la série.

Une condition sans from_status ni to_status s'applique à toutes les transitions ; « rien ne sort de brouillon sans relecture » se déclare une fois, plutôt qu'une fois par transition sortante.

check_conditions ne lève jamais et sert à afficher ce qui bloque, par exemple pour griser un bouton et dire pourquoi. ensure_conditions sert à refuser.

apply_transition consulte le registre, et ne le faisait pas

Le registre existe parce que « deux chemins menant au même état s'oubliaient l'un l'autre, et le second passait sans contrôle ».

Il ne corrigeait pas cela (WORKFLOW-CONDITIONS-APPLIED-001).
apply_transition, la seule fonction du paquet qui sait qu'une transition a lieu, ne le consultait pas : il fallait appeler ensure_conditions à chaque site, donc s'en souvenir à chaque site, donc reproduire exactement le défaut visé.
Mesuré, une condition enregistrée pour refuser le passage à validee n'était jamais appelée, et apply_transition rendait validee.

Ce n'est pas de la magie cachée, c'est l'inverse : l'application a explicitement enregistré ses conditions, et les consulter à l'endroit où une transition a lieu est ce pour quoi le registre existe.

Les conditions passent avant tout effet de bord

L'ordre est : transition déclarée, puis conditions, puis before, commit, after.

before peut écrire : refuser après lui laisserait la trace d'une transition qui n'a pas eu lieu.
Une transition non déclarée est refusée avant les conditions, pour ne pas exécuter du code applicatif sur un passage qui n'existe pas.

Un appel manuel reste sans danger

Une application qui appelait déjà ensure_conditions avant apply_transition les évalue deux fois.

Une condition est un prédicat par contrat, elle rend un motif ou None : la double évaluation est sans effet, et retirer l'appel du contrôleur le simplifie.

Voir aussi