info@altius-group.ch
Froideville, Vaud
FR

Everything hangs off the edition

/ 14 min de lecture / mis à jour 04.09.2026

The Rencontres de Bamako have run since 1994. The site carries the current biennale and every one before it, and the second half is what makes the data model interesting.

Most applications hold a current state. A festival’s site holds a sequence of past states, each of which has to stay true to its year. The 2009 edition had a curator who has since changed jobs, artists whose biographies have since been rewritten, and a rule about how many photographs a project could contain that no longer applies to anything.

None of that may drift. And it drifts by default, because the natural model — one row per person, one row per artist, settings in a settings table — makes today’s values render under yesterday’s pages.

The answer is one pivot and a discipline about what hangs off it.

What belongs to the year

Edition carries far more than dates. It carries the curatorial rules:

# Le nombre d'œuvres qu'un projet peut porter est une décision de
# commissariat, et elle change d'une édition à l'autre : la 15e demande
# « 3 à 5 photographies », une autre pourra demander « 10 photographies,
# une vidéo et des sons ». Elle vit donc SUR L'ÉDITION, à côté de la date
# de clôture des dépôts — pas dans les réglages généraux du site, qui
# imposeraient la même règle à toutes les éditions passées et futures.
projet_min_oeuvres = models.PositiveSmallIntegerField(
    _("œuvres par projet — minimum"), default=3,
    help_text=_("Toutes natures confondues. 0 = aucun minimum."),
)
projet_max_oeuvres = models.PositiveSmallIntegerField(
    _("œuvres par projet — maximum"), default=5,
    help_text=_("Toutes natures confondues. 0 = aucun plafond."),
)

The test for whether something belongs on the edition rather than in site settings is precisely the one in that comment: would putting it in settings impose this rule on past editions too? A general setting is a statement about the site. A curatorial rule is a statement about one biennale, and the 2009 pages must keep describing 2009’s rule.

The featured homepage video gets the same treatment, for the same reason stated more plainly:

# « À la une » de l'accueil : deux articles + cette vidéo. Portée par
# l'ÉDITION et non par les réglages du site, parce qu'une vidéo de mise en
# avant est datée — celle de la 15ᵉ n'a plus rien à faire en tête d'accueil
# quand la 16ᵉ commence. Laissée vide, l'accueil affiche trois articles,
# comme avant.

And the brand colour, which changes every biennale:

brand_color = models.CharField(_("couleur principale"), max_length=7, default="#7850FF")

The consistent shape across all three: empty or zero means “as before”. That is what makes adding a field to an eleven-edition archive safe — none of the existing rows change behaviour when the column appears.

# ZÉRO veut dire « pas de contrainte » — pas de plancher pour un minimum,
# pas de plafond pour un maximum. C'est la convention déjà en place sur les
# limites de médias (cf. SiteSettings.limite) : un zéro qui interdirait
# tout serait un réglage que personne ne saisit volontairement.

That last sentence is the argument for the whole convention. Nobody types 0 meaning “forbid everything”; they type it meaning “no limit”. Matching the semantics people actually intend costs nothing and prevents a class of data-entry accident.

Ten fields, one reader

# Une seule fonction lit ces dix champs, et tout le site l'appelle. Deux
# lectures concurrentes finiraient par ne plus dire la même chose — c'est
# ce qui était arrivé au plafond d'images, écrit à la fois dans le code
# (« 3 à 5 ») et dans les réglages (« 4 »), avec un écran qui annonçait
# l'un et une validation qui appliquait l'autre.

This is the concrete incident behind the rule. The cap on images existed twice — hard-coded as “3 to 5” in the code and configured as 4 in the settings. The upload screen told the artist one number; the validator enforced the other.

The failure that produces is uniquely infuriating for the person on the receiving end: the interface says you may add a fifth photograph, and the form refuses it with no explanation that matches what you were told. It is not a crash, so nothing is logged, and it reproduces only for artists with exactly four works.

One function reads all ten fields, and every screen calls it. That is the whole fix, and it generalises: a rule stated in two places is a rule that will eventually be two rules.

The per-nature fields exist for editions that want a combination rather than a total:

# Le détail par nature, quand l'édition veut une COMBINAISON. Laissé à
# zéro, il n'exige et ne plafonne rien : seul le total ci-dessus compte.
projet_min_photo = ...
projet_max_photo = ...
projet_min_video = ...

And the boundary is drawn where it belongs:

# La BIBLIOTHÈQUE de l'artiste, elle, n'est pas contrainte : il y dépose ce
# qu'il veut, autant qu'il veut. La règle porte sur ce qu'il PRÉSENTE, pas
# sur ce qu'il garde.

The rule applies to what they present, not to what they keep. A curatorial constraint on a submission is not a storage quota, and conflating the two would make artists delete their own working material to satisfy a jury rule.

The same person, eleven times

The archivists filling in past editions found the modelling problem first:

"""
Jusqu'ici, chaque participation exigeait une LIGNE de personne complète :
la même personne existait deux fois, avec deux slugs — donc deux fiches
publiques, deux portraits et deux biographies à tenir à jour.

Ce qui vit ici est ce qui appartient à l'année : la catégorie, le pôle, la
fonction, le rang. Et deux champs FACULTATIFS qui font tout l'intérêt de la
manœuvre :

·  `bio` — la biographie *de l'époque*. Afficher la biographie de 2026 sous
   une participation de 2009 serait faux. Mais on n'oblige personne à
   réécrire les archives : vide, on sert celle de la personne ;
·  `photo` — même raison, un portrait vieillit. Vide, celui de la personne.
"""

A Participation joins a person to an edition and carries what belongs to that year. The two optional override fields are the design, and the sentence in the middle is why they are optional: nobody is obliged to rewrite the archives.

That is the pragmatic answer to a problem that has a purist answer nobody can afford. The purist answer is that every historical page should carry the biography as written at the time — which means someone has to find and retype eleven editions of biographies for a hundred people. The pragmatic answer says: the correct value wins if you have it, today’s value is served if you do not, and the field is there for the day an archivist finds the 2009 programme in a drawer.

The same pattern runs through the artist pages:

C'est le motif des fiches d'artistes, qui montrent le travail de l'édition
consultée et non le dernier en date.

A copied column, and why it is not a mistake

#: Recopie de `poste.unique_par_edition`, et c'est un mal nécessaire.
#: PostgreSQL ne sait pas conditionner un index unique sur une colonne
#: d'une AUTRE table : sans cette copie, la règle « un seul titulaire par
#: édition » ne pourrait vivre que dans du code, donc se contourner. Elle
#: est tenue à jour par `save()` — jamais saisie.
poste_unique = models.BooleanField(default=True, editable=False)

There can only be one general delegate per edition — but “is this post unique?” is a property of the Poste row, and a partial unique index cannot reference a joined table.

So the flag is copied onto the participation, and the comment states the three things that make a denormalisation acceptable: why it exists (a database limitation, not convenience), what it buys (a constraint that cannot be bypassed by code that forgets to check), and who maintains it (save(), never a human — editable=False).

Compare the alternative, which is a clean() method enforcing uniqueness. That works for every write that goes through a form, and not for a management command, a data migration, a shell session, or a bulk import — which is exactly the set of tools the archivists were using.

The post proposes, the year disposes

#: Le rang vivait sur le POSTE, et lui seul. Le raisonnement se tenait —
#: c'est la fonction qui dirige — mais il ne sait pas dire l'exception, et
#: les exceptions existent : une même personne a été délégué général ET
#: commissaire sur une même édition. Pour l'écrire, il fallait fabriquer un
#: poste jumeau, et la production en compte déjà quatre qui ne servent à
#: rien (« Conseiller » à côté de « Conseiller du pôle »…).
#:
#: Le poste PROPOSE désormais, la participation DISPOSE : choisir un poste
#: de tête coche la case, et on la décoche si l'année fait exception.
est_responsable = models.BooleanField(
    _("responsable de son groupe"), default=False, ...
)

The diagnosis here is worth extracting, because it names a specific smell. Seniority lived on the post, which is defensible reasoning — the function is what confers authority. It failed on exceptions, and the workaround was to invent a near-duplicate post to encode one year’s oddity. Production had already accumulated four of them.

Duplicated reference rows are the visible symptom of a missing override. When you find Conseiller sitting next to Conseiller du pôle, the fix is not to tidy the reference table; it is to find the attribute that should have been per-instance and was per-type.

The resolution keeps the default useful: the post still proposes the value — picking a leadership post ticks the box — and the year can override it. Nobody loses the convenience, and the exception becomes expressible.

Renaming across eleven years, carefully

Correcting a person’s name has to reach the archive pages, because those pages read denormalised name columns on the edition rows. And it must not reach too far.

@receiver(pre_save, sender=TeamMember)
def _memoriser_le_nom_precedent(sender, instance, **kwargs):
    """Retenir le nom AVANT l'enregistrement, pour savoir s'il a changé.

    C'est la seule façon de distinguer une correction d'orthographe d'un texte
    d'archive qui nommerait quelqu'un d'autre. Sans le nom d'avant, les deux se
    ressemblent — et la prudence obligerait à ne toucher à rien, donc à laisser
    une ancienne orthographe sur les pages publiques.
    """
    instance._nom_precedent = None
    if instance.pk:
        instance._nom_precedent = (TeamMember.objects
                                   .filter(pk=instance.pk)
                                   .values_list("name", flat=True).first())

The pre_save / post_save pair exists because Django gives you no other way to see the old value. And the docstring explains why the old value is the whole point: without it, a correction and an unrelated archive text look identical, and the safe choice would be to change nothing — leaving a misspelling on the public site forever.

@receiver(post_save, sender=TeamMember)
def _personne_renommee(sender, instance, **kwargs):
    """Une personne renommée l'est PARTOUT.

    On remplace le nom LÀ OÙ IL FIGURE, et nulle part ailleurs : une colonne
    qui nomme quelqu'un d'autre est une donnée d'archive, et elle n'a pas à
    changer parce qu'un homonyme s'est fait corriger.
    """
    ancien = getattr(instance, "_nom_precedent", None)
    if ancien and ancien != instance.name:
        motif = re.compile(re.escape(ancien), re.IGNORECASE)
        for participation in instance.participations.select_related("edition"):
            edition = participation.edition
            ...

Three constraints hold this together. re.escape on a name — because names contain dots, hyphens and parentheses, and an unescaped Marie-Ann is a character class waiting to happen. The scope limited to this person’s own participations, so a correction cannot touch an edition they had nothing to do with. And the replacement applied only where the old spelling actually occurs.

That is as far as an automatic rename can safely go, and stopping there is the right call.

Two switches, both required

Whether an artist may still edit a submission depends on two independent deadlines:

def editions_modifiables(artiste):
    """Celles qu'il peut encore ALIMENTER : les ouvertes, échéance non passée.

    La distinction avec `editions_ouvertes` compte : une édition close reste
    consultable — c'est là que l'artiste relit ce qu'il a autorisé — mais elle
    ne se remplit plus.

    DEUX échéances se cumulent, et il faut les deux : celle de l'ÉDITION, qui
    ferme les dépôts d'une biennale, et celle de la PERSONNE, qui gèle son
    espace (cf. `Artist.espace_gele`). Un candidat non retenu garde son espace
    jusqu'à une date, quelle que soit l'édition qu'il regarde.
    """

The edition’s deadline closes submissions for everyone. The person’s freeze closes one account regardless of edition. They are orthogonal, and collapsing them into one flag would make it impossible to express either case cleanly.

Both are read-only closures, never deletions:

def espace_gele(self):
    """Son espace est-il fermé en écriture ?

    Gelé ne veut PAS dire supprimé : il continue de tout consulter — c'est
    là qu'il relit ce qu'il a autorisé — il ne peut plus rien changer.
    """
#: C'est la « durée de vie » du compte, telle que l'organisation
#: l'a arbitrée : le compte ne meurt pas — il resservira à l'édition
#: suivante, et l'artiste doit pouvoir relire ce qu'il a autorisé — mais le
#: DÉPÔT se ferme, sans quoi 49 dossiers restent modifiables après le
#: bouclage de la communication, et la version imprimée ne correspond plus
#: à ce que dit la base.

Forty-nine files still editable after the communications deadline, and the printed version no longer matches what the database says. That is the concrete reason for the edition deadline, and it is an argument about print production rather than about security.

The reason for read-only rather than closed is consent: the artist must be able to reread what they authorised. A festival holds licences to reproduce work, and the artist’s own record of what they granted has to remain visible after the submission window shuts.

Both defaults are permissive, and both say so:

def depots_artistes_ouverts(self):
    """Les artistes peuvent-ils encore modifier leur dossier ici ?

    Sans date posée, oui — une édition qui n'a jamais eu d'échéance ne doit
    pas se fermer le jour où le champ apparaît. La comparaison se fait sur
    la date locale : une clôture « au 30 novembre » se comprend comme la
    fin de cette journée-là, pas comme minuit UTC.
    """
    if self.artist_deadline is None:
        return True
    return timezone.localdate() <= self.artist_deadline

localdate(), not now(). A deadline “on 30 November” announced to artists in Bamako means the end of that day where they are. Comparing against UTC midnight would close submissions hours early for some of them, and the people affected would be the ones submitting at the last minute — which is most of them.

Observed, not declared — and re-checked at the write

def editions_ouvertes(artiste):
    """Éditions sur lesquelles cet artiste peut déposer.

    L'édition courante, plus celles où il a laissé une trace — un projet ou
    une œuvre déjà rattachés. La participation ne se déclare donc pas, elle se
    constate.
    """

Participation is not declared, it is observed. There is no join table saying who takes part in which edition; the answer is derived from the work that exists. One fewer thing to keep in sync, and one fewer way for the list of participants to disagree with the list of projects.

And the permission is checked twice, on purpose:

def peut_deposer(artiste, edition):
    """Le droit se REVÉRIFIE ici, à l'enregistrement.

    La liste déroulante est construite par `editions_modifiables`, mais une
    liste déroulante se contourne : l'identifiant posté ne vaut rien tant qu'il
    n'a pas été confronté à la même règle, côté serveur.
    """

The dropdown is a convenience. The check is the rule. Anyone can post an edition id.

The escape hatch is explicit and narrow:

def modifiable(objet, au_nom_de_lorganisation=False):
    """`au_nom_de_lorganisation` LÈVE les échéances — celle de l'édition comme le
    gel de la personne. C'est le sens de l'écran par lequel l'équipe dépose à
    la place d'un artiste : il sert justement quand l'artiste ne peut pas, et
    souvent tard.
    """

The team’s on-behalf-of screen exists precisely for the cases the deadlines are meant to catch. A deadline the organisers cannot override is a deadline that turns into a phone call and a database edit.

What carries over

  • Ask whether a setting would apply to the past. If it would, it belongs on the period, not in site settings.
  • Empty or zero means “as before”. It is what makes a new column safe on an eleven-year archive, and it matches what people intend when they type it.
  • One reader for a rule. Two readings become two rules, and the symptom is a screen that announces one number while validation enforces another.
  • Overrides are optional and fall back to today. The archive gets more correct as someone fills them in, and nothing is blocked until they do.
  • Duplicated reference rows mean a missing per-instance override. Fix the model, not the reference table.
  • Freeze, do not delete. Someone whose account is closed for writing still needs to reread what they consented to.
  • Compare deadlines in the local date of the people they bind.
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.