info@altius-group.ch
Froideville, Waadt
DE

Documentation that shows the holes

/ 13 Min. Lesezeit / aktualisiert 04.09.2026

The complaint came in four words: on va à l’aveugle. We are working blind.

The admissions workflow of ENI-ABT is administrable, which was the whole point of building it that way. A statuses table, a transitions table, a document-states table. Add a step to the process and you add a row — no deployment, no developer.

The cost of that is a workflow that exists nowhere in readable form. It is spread across three tables and nobody can hold it in their head. So you discover its gaps by walking into them:

raise TransitionInterdite(
    _("Passage de « %(depuis)s » à « %(vers)s » non prévu par le circuit.")
    % {"depuis": self.get_statut_display() or "—", "vers": nouveau.libelle}
)

Moving from “Incomplete” to “Submitted” is not provided for by the workflow. Which is an exact, honest refusal — and useless. A candidate is stuck, an agent is looking at a button that does not work, and nothing anywhere says which way they should have gone.

Why not just draw it

The reflex is a diagram in the wiki. It fails for a reason that is structural rather than lazy:

"""
Un schéma dessiné à la main aurait été faux dans un mois : ce circuit est
administrable, et c'est sa raison d'être. Celui-ci se relit de la base à chaque
fois qu'on le demande. Il montre donc ce qui est, y compris les trous.
"""

A hand-drawn diagram would have been wrong within a month: this workflow is administrable, and that is its reason for existing.

A workflow you can change from a screen and a diagram you maintain by hand are mutually exclusive commitments. Whichever one you keep, the other rots — and the one that rots silently is the diagram, because nothing fails when it goes stale. It just gets quietly less true until someone follows it into a wall.

So: generate it. python manage.py schema_admission, writing Markdown with Mermaid blocks.

docker compose exec -T eni_abt_web python manage.py schema_admission
docker compose exec -T eni_abt_web python manage.py schema_admission --fichier docs/parcours-du-dossier.md

Markdown-with-Mermaid rather than SVG or a rendered PNG, for one practical reason: it reads as text in an editor, renders as a diagram on the forge, and diffs. A regenerated PNG is one opaque blob replacing another; a regenerated Markdown file shows you, in the diff, exactly which arrow appeared last week.

And the generated file says so about itself, at the top:

return (
    "# Le parcours d'un dossier d'admission\n\n"
    f"Campagne décrite : **{titre}**.\n\n"
    "Ce fichier est **produit depuis la base** par\n"
    "`python manage.py schema_admission`. Ne pas le corriger à la main :\n"
    "corriger la base, et le régénérer. Ce qu'il montre est ce qui est,\n"
    "y compris ce qui manque."
)

Do not correct this file by hand: correct the database and regenerate it. Any generated artefact that lives in a repository needs that sentence, because somebody will otherwise fix a typo in it and be baffled when the fix vanishes.

Two functions nobody wants to write

Before anything interesting, the boring part that makes the output valid:

def _noeud(code):
    """Un identifiant Mermaid : ni tiret, ni accent, ni espace."""
    return code.replace("-", "_").replace(".", "_")


def _texte(valeur):
    """Un libellé de nœud : les guillemets et les crochets y sont interdits."""
    return str(valeur).replace('"', "'").replace("[", "(").replace("]", ")")

Mermaid is a language, and a status label typed by a registrar is untrusted input to it. A label containing [ or a quote does not produce a wrong diagram; it produces a parse error, which renders as a red box where the workflow should be. Since the labels are administrable, that failure is one careless Dossier [en attente] away, and it would be blamed on the diagram rather than on the label.

Two three-line functions is the entire mitigation, and it is worth naming the general shape: anything you generate in a syntax needs escaping at the boundary, even when the syntax is “just documentation”.

The circuit, and the requirements on the arrows

The state diagram is a straight walk over two ordered querysets:

statuts = list(StatutDossier.objects.order_by("ordre"))
transitions = list(
    TransitionStatut.objects.filter(actif=True)
    .select_related("depuis", "vers")
    .order_by("ordre")
)

What makes it useful rather than decorative is that the arrow labels carry the preconditions, not just the action name:

exigences = []
if transition.exige_pieces_completes:
    exigences.append("pièces complètes")
if transition.exige_paiement:
    exigences.append("frais réglés")
if transition.exige_motif:
    exigences.append("motif exigé")
etiquette = _texte(transition.libelle)
if exigences:
    etiquette += " (" + ", ".join(exigences) + ")"

Those three booleans are exactly the ones the guard enforces at runtime:

if transition.exige_motif and motif is None:
    raise TransitionInterdite(
        _("« %(action)s » exige un motif.") % {"action": transition.libelle}
    )
if transition.exige_pieces_completes and not self.complet:
    raise TransitionInterdite(
        _("Toutes les pièces obligatoires ne sont pas validées.")
    )
if transition.exige_paiement and not self.paye:
    raise TransitionInterdite(_("Les frais ne sont pas réglés."))

Same three fields, read by the guard and by the generator. That is the property that keeps this documentation honest: it is not a description of the rules, it is a rendering of them. There is no version of the diagram that can disagree with the code, because both read the same rows.

Statuses get two markers, and the legend is one line:

marque = " 🔓" if statut.depot_ouvert else (" ⏹" if statut.final else "")
🔓 le candidat peut téléverser · ⏹ statut final

depot_ouvert is a real field with real consequences — it opens uploading in the candidate’s area — and putting it on the diagram means an administrator can see, at a glance, every status from which a candidate still has a say. That turns out to matter a great deal two sections down.

The transition that starts from anywhere

One modelling decision needs special handling when drawn:

depuis = models.ForeignKey(
    StatutDossier, ..., null=True, blank=True,
    help_text=_("Vide : la transition est possible depuis n'importe quel statut."),
)

A null depuis means from any status — that is how “cancel the application” is declared once instead of eleven times. Drawing it faithfully would mean an arrow from every node to one node, which is a hairball.

if transition.depuis_id is None:
    # « Depuis n'importe quel statut » : une flèche par statut
    # serait illisible, on la dessine une fois depuis chacun des
    # statuts non finaux.
    for statut in statuts:
        if statut.final or statut.pk == transition.vers_id:
            continue
        lignes.append(...)
    continue

Final statuses are excluded, and so is the target itself. Both exclusions mirror the runtime: a final status is terminal, and changer_statut returns early when the new status equals the current one.

if nouveau == self.statut:
    return None

The resolution order in the guard is worth quoting alongside, because it is the subtle half of this feature:

transition = (
    TransitionStatut.objects.filter(actif=True, vers=nouveau)
    .filter(models.Q(depuis=self.statut) | models.Q(depuis__isnull=True))
    # Une transition explicite prime sur la règle générale « depuis
    # n'importe quel statut » : les valeurs nulles passent en dernier.
    .order_by(models.F("depuis").asc(nulls_last=True))
    .first()
)

An explicit transition beats the catch-all. Without nulls_last=True, a general “cancel from anywhere” rule with no requirements would shadow a specific “cancel from accepted” rule that demands a reason — and the reason would stop being asked for, silently, with no error anywhere.

The section that earns the command

Sections 1 to 3 draw what is there. Section 4 is the one that changes how the tool is used:

def _les_trous(self):
    """
    Ce que le circuit ne permet pas et qu'on lui demande pourtant.

    C'est la partie qui sert : un circuit se lit mal, ses impasses se lisent
    encore plus mal. Celles-ci se découvraient en s'y cognant, un candidat
    bloqué à la fois.
    """

A workflow reads badly; its dead ends read worse. These were discovered by walking into them, one stuck candidate at a time.

Two checks. The first is a genuine invariant of this domain:

manques = []
for code, statut in statuts.items():
    if statut.depot_ouvert and not statut.initial and not possible(code, "depose"):
        manques.append(
            f"- Depuis **{statut.libelle}**, le candidat peut téléverser "
            "mais ne peut pas redéposer : il est dans une impasse."
        )

Read that condition as a sentence: this status invites the candidate to upload documents, and offers them no way to resubmit afterwards. That is a trap with a welcome mat. It is not detectable by looking at either table alone — you need depot_ouvert from one and the absence of a row in the other — which is exactly why nobody found these by reading the admin screens.

The second check is structural:

atteints = {vers for _depuis, vers in arrivees}
orphelins = [
    statut.libelle
    for code, statut in statuts.items()
    if code not in atteints and not statut.initial
]

Statuses no transition leads to. They exist in the table, they show up in filter dropdowns, and no action in the application can put a file into one.

And here is the sentence I would keep from the whole module:

manques.append(
    "- Aucune transition ne mène à **"
    + "**, **".join(orphelins)
    + "**. Ces statuts existent en base et rien ne peut les "
    "atteindre : soit ils sont décidés ailleurs qu'au circuit — la "
    "délibération, par exemple —, soit un passage manque."
)

Either they are decided somewhere other than the workflow — at the deliberation, for instance — or a transition is missing.

The generator does not know which. It cannot: distinguishing “deliberately set outside the workflow” from “we forgot an arrow” requires knowing intent, and intent is not in the database. So it reports the fact and names both readings.

That restraint is what makes the section trustworthy. A checker that guessed — that called every unreachable status a bug — would cry wolf on the statuses set by the admissions board, and within two regenerations everyone would learn to skip section 4. The comment in the source says it plainly:

# Les distinguer demande de connaître l'intention ;
# les signaler ne demande que de savoir lire.

Telling them apart requires knowing the intent; flagging them only requires being able to read. Do the part you can do correctly, and hand the judgement to the person who has the context.

The no-findings branch is not silence either:

return (
    "## 4. Ce qui n'a pas de chemin\n\n"
    "Rien : depuis chaque statut qui rend la main au candidat, le "
    "circuit lui permet de redéposer, et chaque statut est "
    "atteignable."
)

A section that disappears when it finds nothing is indistinguishable from a section that failed to run. Stating what was checked and found clean is what makes the absence of findings meaningful.

Drawing what is closed

The same principle runs through section 1, which maps public entry points to admission tracks:

forme = "([%s])" if voie.ouverte else "[/%s\\]"
lignes.append(f"  V_{_noeud(voie.code)}" + forme % _texte(voie.libelle))
fermees = [v.libelle for v in voies if not v.ouverte]
if fermees:
    lignes += [
        "",
        "Les voies en trapèze sont **fermées** : elles existent en base et",
        "n'accueillent personne — " + ", ".join(fermees) + ".",
    ]

Closed tracks are drawn as trapezoids and named in prose. Omitting them would produce a cleaner diagram and a worse one: this track exists and is closed and this track does not exist are different situations with different fixes, and a diagram that renders them identically is actively misleading during a campaign where somebody is asking why nobody is applying through a given route.

The edges are only drawn for open tracks, so the picture stays a picture of what works — while the text below it accounts for the rest.

Documentation as a design note

Section 5 does something a generated file does not usually do: it states what is missing for a feature that has not been built.

"""
C'est là que se raccrochera la réinscription des étudiants déjà dans le
circuit : le passage en année supérieure n'est pas un concours, mais il
demande lui aussi des pièces. Ce qu'il faudra lui donner se lit dans ce
tableau — ce que la porte « étudiant » a déjà, et ce qui lui manque.
"""

The rendered output carries the same reasoning:

**Ce qui manque pour la réinscription.** Le passage d'un étudiant
en année supérieure n'est pas un concours, mais il réclame des
pièces — et une porte sans dossier n'en réclame aucune. Il lui
faudra donc l'équivalent d'une voie [...] Tout le reste — le dépôt,
le contrôle, les états de pièce, la chronologie — fonctionne déjà
sans rien connaître du concours.

This is a design note pinned to the table that motivates it, and regenerated next to the current state of that table. Put the same paragraph in a wiki page and it drifts from the data it describes; put it here and it is re-emitted beside the evidence every time somebody runs the command.

There is a limit to that trick, and it is honest to state it: the paragraph itself is hard-coded in the generator, so it does not update when the reality changes. If somebody adds document requirements to a door tomorrow, the “what is missing” text will still claim they are missing. What the generator guarantees is that the paragraph is read next to the current table — not that it is correct. That is a real improvement over a wiki, and less than it looks.

The small things that keep it readable

#: Bornes d'affichage : un schéma qu'on ne peut pas lire ne sert à rien.
NB_VOIES = 30
NB_PIECES = 40

A diagram with two hundred nodes is not documentation, it is a stress test for the renderer. Truncating is the right call and the number should be a named constant with that sentence next to it, so the next person raising it knows what they are trading away.

individuelles = TypePiece.objects.filter(
    session=session, individuelle=True
).count()

Documents demanded of one specific person during their case review are counted separately and deliberately kept out of the per-track table:

Elles ne figurent sur la liste d'aucune voie, et la réconciliation ne les
pose ni ne les retire : c'est un agent qui les a mises là.

Mixing them in would suggest an entire cohort is being asked for a residence certificate. The distinction costs one count() and a paragraph, and it is the difference between a table that can be acted on and one that has to be double-checked.

What carries over

  • If the thing is administrable, its documentation must be generated. The two commitments cannot both be kept by hand, and the diagram is the one that rots without failing.
  • Generate a text format that diffs. Markdown with Mermaid renders on the forge and shows you which arrow appeared this week; a PNG shows you that a PNG changed.
  • Escape at the boundary, even for documentation. Administrable labels are untrusted input to whatever syntax you emit.
  • Render the guard’s own fields onto the diagram. Reading the same rows as the runtime is what makes the picture impossible to falsify.
  • Report what has no path, and refuse to guess why. Name both readings; the person with the context will pick. A checker that guesses gets ignored.
  • Say “nothing found” out loud. A section that vanishes on success looks exactly like a section that crashed.
Bereit loszulegen?

Sprechen wir über Ihr Projekt

Erzählen Sie uns von Ihrem Bedarf in IoT, GIS oder individueller Entwicklung — wir melden uns innerhalb von 24 Stunden.