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 :
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¶
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) :
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¶
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¶
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 é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¶
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
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
WorkflowStatusporte un nom, un libellé, une couleur, des marqueurs initial/final ; - une
WorkflowTransitionrelie un statut de départ à un statut d'arrivée ; can_transitionré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_transitionsalimente 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¶
get_available_transitions(TRANSITIONS, article.status) donne les boutons d'action à proposer.
Aide-mémoire
Déclarer, vérifier, afficher :
make_status/make_transitionpour le cycle ;can_transition/get_available_transitionspour 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¶
- Statuts (status.py) :
WorkflowStatus, validation des noms. - Transitions (transitions.py) :
can_transition, transitions disponibles. - Helpers Jinja (jinja.py) : badges et libellés.
- Welcome-Workflow : parcours d'apprentissage.