info@altius-group.ch
Froideville, Vaud
FR

One seat, one candidate

/ 13 min de lecture / mis à jour 04.09.2026

The Convocation model had existed since the first migration. So had the printed layout. The exam calendar even carried a button reading Generate convocations.

Production held zero convocations for sixty-three exams.

"""
C'est la pièce qui manquait au milieu du parcours, et elle manquait entièrement.
Le modèle `Convocation` existe depuis l'origine, l'impression aussi, le
calendrier porte même un bouton « Générer les convocations » — et ce bouton
n'imprimait qu'une liasse vide : **rien, dans toute l'application, ne créait une
convocation**. La production en compte zéro pour soixante-trois épreuves.
"""

Nothing, in the whole application, created a convocation. The button printed an empty stack. And the template carrying that button was rendered by no view at all.

Meanwhile the candidate had done their part: file accepted, fees paid at the counter. They were waiting for a date and a place. The school had both — they are on the exam session row — and no way to hand them over except by telephone.

This is the piece in the middle of ENI-ABT, the admissions platform of an engineering school in Bamako. Two hundred lines, and the reason it took thought rather than typing is that a seat is not a database row. It is a physical object in a room, there is a fixed number of them, and two people cannot have the same one.

Three rules that hold each other up

"""
**On ne convoque que ce qui est payé.** Une place en salle d'examen se réserve,
et la réserver à qui n'a pas réglé revient à la retirer à quelqu'un d'autre.

**Une place, un candidat.** [...] deux convocations lancées en même temps ne
doivent pas asseoir deux personnes au même endroit.

**Convoquer deux fois ne convoque pas deux fois.** Un candidat déjà convoqué à
une séance de la session est passé ; l'appel est rejouable, et c'est ce qui
permet de rattraper ceux qui ont payé après le premier envoi.
"""

Each of the three is a consequence of the seat being scarce. Reserving one for somebody who has not paid takes it away from someone else. Two convocations for one seat means two people standing in a doorway on exam morning. And a run that cannot be repeated means the candidates who paid yesterday afternoon never get called.

The eligibility filter never names a status:

dossiers = (
    Dossier.objects.filter(
        session=seance.session_id, statut__ouvre_le_paiement=True
    )
    ...
)

ouvre_le_paiement is a boolean on the status row, and its help text says what it is for:

ouvre_le_paiement = models.BooleanField(
    _("ouvre le paiement des frais"),
    default=False,
    help_text=_(
        "Le guichet n'encaisse que depuis ce statut. Coché sur le seul "
        "« Candidature acceptée » : on n'encaisse pas dix mille francs "
        "auprès de quelqu'un dont le dossier n'est pas recevable."
    ),
)

The counter takes money only from that status, and the convocation calls only from that status. Same flag, two places, one source of truth — so a school that adds an intermediate status next year changes one checkbox and both behaviours follow. Writing statut__code="accepte" here would have worked identically today and silently diverged the first time the workflow was edited, which is the thing this platform is built to allow.

"""
Le drapeau lu est `ouvre_le_paiement` sur le statut, jamais un code écrit ici.
"""

Then a filter that cannot be a filter:

# `paye` interroge les transactions : le tri se fait en Python, sur une
# liste déjà bornée par la capacité de la salle un cran plus loin.
return [dossier for dossier in dossiers if dossier.paye]

paye inspects payment transactions, so it is a property and not a column. This is the ordinary ORM dilemma — express it as a subquery and gain a database-side filter, or evaluate in Python and gain readability — and the comment resolves it by pointing at the bound: the list is about to be truncated to the room’s capacity anyway. A few hundred rows through a Python loop is not a problem; a hand-written Exists() subquery over the payments table, maintained alongside the paye property, is two definitions of paid that can disagree.

The ordering matters too:

.order_by("depose_le", "pk")

First filed, first seated. When there are more paid candidates than seats — and in a room of two hundred there will be — the rule by which some are seated and others are not has to be defensible to the person who was not. Submission date is defensible. Primary key breaks ties deterministically, so a rerun produces the same assignment.

One seat, one candidate — the constraint first

The uniqueness lives in PostgreSQL, and the interesting part is its condition:

constraints = [
    models.UniqueConstraint(
        fields=["dossier", "seance"], name="une_convocation_par_dossier_et_seance"
    ),
    # Deux candidats ne peuvent pas recevoir la même place dans la même
    # salle. La contrainte ignore les places non encore attribuées.
    models.UniqueConstraint(
        fields=["seance", "place"],
        condition=~models.Q(place=""),
        name="une_place_par_seance",
        violation_error_message=_("Cette place est déjà attribuée dans cette salle."),
    ),
]

Without condition=~Q(place=""), the second constraint would allow exactly one unassigned convocation per session — because ("seance", "") is a value like any other, and the second one would collide. A partial unique index is the correct shape whenever the “empty” state is legitimate and repeatable, and forgetting it produces a bug that only appears on the second row.

The violation_error_message matters for a different reason: this constraint can be hit from a form, and the default Django message names the constraint. An agent seating a candidate by hand should read this seat is already taken in this room, not une_place_par_seance.

The exam session carries the analogous rule for rooms:

# Une même salle ne peut pas accueillir deux épreuves au même moment.
models.UniqueConstraint(
    fields=["centre", "salle", "date", "heure"],
    name="une_seule_seance_par_salle_et_creneau",
    violation_error_message=_("Cette salle est déjà occupée à ce créneau."),
)

Finding a seat, and the race that follows

The allocator reads the taken seats once, then walks upward:

prises = set(
    Convocation.objects.filter(seance=seance)
    .exclude(place="")
    .values_list("place", flat=True)
)
def _place_libre(seance, prises):
    """Rend la première place libre de la séance, ou une chaîne vide."""
    for numero in range(1, capacite(seance) + 1):
        place = f"{numero:0{LARGEUR_PLACE}d}"
        if place not in prises:
            return place
    return ""

The docstring for the rule explains why this is a search for a free seat rather than a count:

"""
La base le contraint déjà — unicité `(séance, place)` quand la place est
attribuée — et l'on cherche donc la première libre plutôt que de compter les
convocations existantes : deux convocations lancées en même temps ne doivent pas
asseoir deux personnes au même endroit.
"""

Counting gives you n + 1, which is wrong the moment any seat has been released or assigned by hand, and wrong under concurrency in a way that produces duplicates rather than gaps. Searching for the first hole is right in both cases.

But the read and the write are not atomic, and the code does not pretend otherwise:

except Exception:
    # Une place ravie entre le relevé et l'écriture : la contrainte
    # d'unicité fait son travail, et l'appelant le compte comme un refus
    # plutôt que de tomber au milieu d'une liste de deux cents candidats.
    journal.info("convocation non écrite pour %s (place %s)", dossier, place)
    return None

A seat snatched between the read and the write: the unique constraint does its job, and the caller counts it as a refusal rather than crashing halfway through a list of two hundred candidates.

This is the design decision I would defend hardest. The alternative — SELECT ... FOR UPDATE on the session, or a lock around the whole run — is more correct-looking and worse here: it serialises a batch that takes minutes because it sends email and SMS, and it converts a rare collision into a guaranteed queue. Letting the database refuse the duplicate and treating the refusal as data is cheaper and, crucially, partial: the other one hundred and ninety-eight candidates are still seated.

The caller records it by name:

convocation = _convoquer(dossier, seance, place, acteur)
if convocation is None:
    refus.append((str(dossier), _("place déjà prise entre-temps")))
    continue

A report that names its refusals

def convoquer_seance(seance, acteur=None, requete=None):
    """
    Convoque à cette séance ceux qui l'attendent. Rend `(faites, refus)`.

    `refus` nomme ce qui n'a pas pu partir et pourquoi — une salle pleine, une
    adresse absente. Un compte rendu qui n'annonce qu'un nombre fait croire que
    tout le monde a été prévenu, et c'est exactement l'erreur que la relance
    commettait avant d'être reprise.
    """

A report that announces only a number makes you believe everyone was notified. This is a lesson learned elsewhere in the same codebase and carried here: (198, [("DIARRA Fatoumata", "la salle est pleine")]) is an actionable result. 198 is a number that an agent reads as done.

The two failure reasons the run can produce are the two that are actually possible: the room filled up, and the seat was taken in between. Neither is a programming error; both need a human to decide what happens next.

Bounding the loop

#: Plafond d'une séance quand ni elle ni son centre n'annoncent de capacité.
#: Borne la boucle (règle 2) : sans elle, convoquer une session entière dans une
#: salle non renseignée tournerait sur tous les dossiers payés de la campagne.
CAPACITE_PAR_DEFAUT = 200


def capacite(seance):
    """Combien de candidats cette séance peut asseoir."""
    if seance.capacite:
        return seance.capacite
    if seance.centre_id and seance.centre.capacite:
        return seance.centre.capacite
    return CAPACITE_PAR_DEFAUT

Three tiers: the session’s own capacity, the centre’s, then a constant. The constant is not a guess about room sizes — it is a bound. A session created without a capacity, in a centre created without a capacity, is a data-entry omission that will happen; without the fallback, that omission turns into a run that tries to seat every paid candidate in the campaign into one unmeasured room.

And the bound is asserted rather than assumed:

attendus = a_convoquer(seance)[:places]
exiger(len(attendus) <= places, "convocation non bornée", nombre=len(attendus))

The slice already guarantees it. The assertion is there because the day someone changes a_convoquer to return a queryset, the slice will still be there and the guarantee may not.

Three digits, for a list taped to a door

#: Format de la place. « 042 » se lit sur une liste affichée à la porte et se
#: trie ; « 42 » se perd entre 4 et 421.
LARGEUR_PLACE = 3

The seat number is stored as a zero-padded string because it is sorted as a string — in the ordering = ("seance__date", "place") of the model, in the printed list, and in the spreadsheet somebody will inevitably export. Naked integers sort as 1, 10, 100, 11, 2 in every one of those contexts, and the list on the door is read by candidates who are already anxious.

This is a three-line constant carrying a design decision about a piece of paper in a courtyard, and it is the kind of thing that gets “cleaned up” into an IntegerField by someone who has never seen the door.

Two channels, and one is enough

# Les deux canaux, et l'un suffit à dater l'envoi. La convocation est
# le message qu'on relit le matin de l'épreuve, dans une cour de lycée,
# sans connexion : c'est celui qui a le plus à gagner à exister aussi
# dans le téléphone du candidat.
courriel = envoyer_convocation(convocation, requete)
message = envoyer_convocation_sms(convocation)
if courriel or message:
    convocation.envoyee_le = timezone.now()
    convocation.save(update_fields=["envoyee_le"])

The convocation is the message you reread on the morning of the exam, in a school courtyard, with no connection. That single sentence is the argument for paying for SMS on this one notification and not on others.

if courriel or message — either channel succeeding marks it sent. The alternative readings are both wrong: requiring both means a candidate with no email address is never marked as notified even though their SMS arrived, and marking it sent unconditionally means a hard bounce looks like a delivery. or is the honest middle: at least one route out worked.

update_fields=["envoyee_le"] keeps the write to one column, which matters because this row was created moments earlier in a transaction that has since committed, and rewriting the whole row would re-persist values another process may have touched.

The convocation and its trace, together

with db_transaction.atomic():
    convocation = Convocation.objects.create(
        dossier=dossier, seance=seance, place=place
    )
    EvenementDossier.objects.create(
        dossier=dossier,
        type_evenement=TypeEvenement.objects.filter(
            code=CodeEvenement.CONVOCATION
        ).first(),
        libelle=_("Convocation à « %(epreuve)s »") % {"epreuve": seance.epreuve},
        detail=_("%(date)s à %(heure)s — %(centre)s, salle %(salle)s, place %(place)s.")
        % {...},
        acteur=acteur,
    )
"""
Tout dans la même transaction : une convocation sans sa trace laisserait
l'agent devant une liste qui ne dit pas d'où elle vient, et une trace sans
convocation ferait chercher un document qui n'existe pas.
"""

Note that the timeline entry duplicates the date, time, centre, room and seat as text. That looks like denormalisation and it is deliberate: the timeline is a record of what was communicated, at the moment it was communicated. If the exam is later moved to another room, the row must still say what the candidate was originally told — because that is the question anyone will actually ask when somebody turns up in the wrong place.

The notification carries the same reasoning down to the title:

# La date dans le titre : c'est la seule chose qu'on cherche en
# rouvrant un avis de convocation, et souvent des semaines plus tard.
_("Convocation — %(epreuve)s le %(date)s") % {...}

And the body ends with the operative instruction rather than a status:

Imprimez votre convocation depuis votre dossier et présentez-la avec une
pièce d'identité. Sans elle, l'accès à la salle peut vous être refusé.

That matches a rule stated on the status model itself — say what they must do, not the state they are in: they can already see that.

Replayable, by construction

deja = set(
    Dossier.objects.filter(
        session=seance.session_id, convocations__seance__session=seance.session_id
    ).values_list("pk", flat=True)
)

Note the scope: already convoked anywhere in this session, not to this exam session row. A candidate sits once. Running the call again the day after tomorrow picks up the people who paid in between and leaves everyone else untouched — which is the whole point, because payments at a physical counter do not stop arriving because a batch was run.

That exclusion set is also what makes the operation safe to hand to an agent with a button. An operation that is dangerous to repeat is an operation that needs a confirmation dialog, a lock, and a support call when someone clicks it twice.

What carries over

  • When the resource is physical, the database constraint is the truth. Search for a free slot, let the unique index arbitrate, and treat its refusal as a data point rather than an exception.
  • Partial unique indexes for legitimate empties. condition=~Q(place=""), or your second unassigned row collides with your first.
  • Never fail the batch for one row. Two hundred candidates, one collision, one hundred and ninety-nine convocations and a named refusal.
  • Return refusals with reasons. A count alone reads as success.
  • Bound every loop that walks a candidate set, including with a constant whose comment says it is a bound and not an estimate.
  • Read the flag the rest of the system reads. ouvre_le_paiement, not a status code — otherwise the administrable workflow becomes administrable in name only.
Prêt à démarrer ?

Parlons de votre projet

Dites-nous vos besoins en IoT, SIG ou développement sur mesure — nous vous répondons sous 24 h.