The delete-my-account feature is usually one button and one user.delete(). It
is correct, it is what the law requires, and it is wrong in a way that only shows
up in support.
The change came from the client, not from us:
"""
Le site supprimait sur-le-champ, sur un seul bouton. Le client l'a jugé « trop
facile », et il a proposé mieux que ce que nous proposions : **geler le compte,
et supprimer les données au bout d'un certain temps**. La raison n'est pas
technique. La plupart des suppressions sont des gestes de colère ou d'erreur, et
un délai les rattrape : sans lui, un artiste qui ferme son compte un soir de
dépit a perdu quinze œuvres et un dossier de communication que personne ne peut
reconstituer.
"""
The site deleted immediately, on one button. The client found it “too easy” and proposed better than what we were proposing: freeze the account, delete the data after a delay. The reason is not technical. Most deletions are gestures of anger or error, and a delay catches them.
This is the account closure in the platform of the Rencontres de Bamako, the African biennial of photography — where the account being deleted belongs to an artist, and the data being erased is fifteen works and a submitted application that took weeks to assemble.
Almost every decision in the module is about what the person actually meant, and most of them are three lines long.
Two states, and a word you may not reuse
"""
VOCABULAIRE. On dit « clôture », jamais « gel ». Le mot *gelé* est déjà pris ici
et veut dire autre chose — `Artist.espace_gele`, l'espace fermé en écriture
après l'échéance d'une édition, alors que la personne existe toujours et relit
son dossier. Deux sens pour un mot, et plus personne ne relit ces écrans.
"""
We say “closure”, never “freeze”. The word frozen is already taken here and means something else — the workspace closed for writing after an edition’s deadline, while the person still exists and is rereading their file. Two meanings for one word, and nobody reads these screens any more.
A vocabulary note, at the top of the module, before any code.
Both states are “the account is not fully usable”, and they are completely
different: espace_gele is a deadline passing — the artist is still there, still
logged in, reading their own submission. Closure is the account being on its way
out of existence.
If both are called “frozen”, then every conditional, every template, every support conversation and every future developer has to disambiguate from context. The cost is not confusion in the code — a developer reads the field name. The cost is in the screens: two different messages that both say frozen, and a support agent who can no longer tell a customer what state they are in.
Naming discipline is usually presented as an aesthetic. Here it is a decision about whether an interface remains readable, and it is worth writing at the top of a module in capitals so that the next person does not helpfully unify the two.
Show what disappears, counted
def ce_qui_disparait(user):
"""Ce que la clôture emportera, compté et nommé.
Compté, et non décrit en général : « vos données seront supprimées » ne dit
rien à personne. « 3 projets, 27 œuvres » se lit, et fait parfois reculer —
c'est bien le but.
"""
lignes = []
candidatures = user.applications.count()
if candidatures:
lignes.append({"quoi": _("candidature(s)"), "combien": candidatures})
artiste = getattr(user, "artist_profile", None)
if artiste is not None:
for libelle, combien in ((_("projet(s)"), artiste.projects.count()),
(_("œuvre(s) dans votre bibliothèque"),
artiste.works.count())):
if combien:
lignes.append({"quoi": libelle, "combien": combien})
return lignes
Counted, not described in general: “your data will be deleted” says nothing to anybody. “3 projects, 27 works” gets read, and sometimes makes people step back — which is the point.
“Your data will be permanently deleted” is a sentence people have read a thousand times and have learned to scroll past. 27 is a number, and a number about their own work. Somebody who has forgotten they uploaded twenty-seven images finds out at the moment it matters.
Two implementation details make it honest:
Empty categories are omitted. if combien: — a person with no applications
does not read “0 applications”. Zeros pad the list and make the real numbers
harder to see, which is precisely backwards on a screen whose job is to make the
real numbers land.
getattr(user, "artist_profile", None) — not every account is an artist.
There are jurors, curators, staff. A confirmation screen that crashes on a
RelatedObjectDoesNotExist for a non-artist is a screen nobody can use to leave,
which is its own kind of violation.
This is worth generalising: a destructive confirmation should be an inventory, not a warning. The warning tells the person you have covered yourself. The inventory tells them what they are about to lose.
Two things typed by hand, and both are translated
#: Le mot à retaper. Traduit : demander « SUPPRIMER » à quelqu'un qui lit le
#: site en anglais, c'est lui demander de recopier une chaîne qu'il ne comprend
#: pas — ce n'est plus une confirmation, c'est un péage.
def mot_de_confirmation():
return _("SUPPRIMER")
Asking someone reading the site in English to retype “SUPPRIMER” is asking them to copy a string they do not understand — that is no longer a confirmation, it is a toll.
The GitHub-style “type the name of the repository to confirm” pattern, and one detail almost everyone gets wrong: the word must be in the reader’s language.
The mechanism only works if the person understands what they are typing. A French word presented to an English reader degrades into a copy-paste exercise — it still costs them ten seconds, and it no longer confirms anything. It has become friction without meaning, which is the worst possible outcome: the cost is paid and the benefit is not.
The second entry is the person’s own identity, and it carries the detail I like most in this codebase:
def _sans_accent(texte):
"""« Traoré » et « traore » sont la même personne.
Une confirmation qui exige les accents n'est plus une confirmation : c'est
un piège tendu à quelqu'un qui tape sur un clavier qui ne les a pas.
"""
decompose = unicodedata.normalize("NFD", (texte or "").strip().casefold())
return "".join(c for c in decompose if not unicodedata.combining(c))
A confirmation that requires accents is no longer a confirmation: it is a trap set for someone typing on a keyboard that does not have them.
NFD decomposes é into e plus a combining acute accent; dropping every
combining character leaves the base letters. Combined with casefold() — which is
the correct one for comparison, not lower(), because it handles cases lower()
does not.
The reasoning is about who is using the platform. A Malian photographer on a
borrowed phone with an English keyboard layout cannot type Traoré. Refusing
traore does not verify anything about their intent; it verifies their keyboard.
This is what localisation looks like when it goes past translating strings. The
_() calls are the easy half. The hard half is noticing that a string comparison
encodes an assumption about hardware.
And the expected value has a fallback:
def identite_attendue(user):
"""Son nom complet — et son adresse e-mail à défaut. Beaucoup de comptes
n'ont pas de nom : ils se créent avec une adresse et rien d'autre. Leur
demander « retapez votre nom » alors qu'aucun nom n'est enregistré ferait
d'une confirmation une impasse.
"""
return user.full_name or user.email
Ask for a name that was never collected and the confirmation cannot be satisfied by anyone — the account becomes undeletable, and the only route out is a support request. A confirmation that some users cannot pass is not a strong confirmation; it is a bug that looks like security.
Everything goes at once, except the erasure
def clore(user, motif="", message=""):
"""Ferme le compte SANS rien effacer. Renvoie la date d'effacement prévue.
Tout part en même temps — la connexion et la visibilité publique. Laisser
la fiche en ligne le temps du délai serait le pire des demi-gestes : la
personne a demandé à disparaître, elle doit disparaître tout de suite ; ce
qui attend, c'est l'effacement, pas le retrait.
"""
user.cloture_demandee_le = timezone.now()
user.cloture_motif = motif or ""
user.cloture_message = (message or "").strip()
user.is_active = False
user.save(update_fields=[...])
_retirer_du_site_public(user, publier=False)
return efface_le(user)
The person asked to disappear, they must disappear immediately; what waits is the erasure, not the withdrawal.
This is the line that makes a grace period ethical rather than a dark pattern.
The lazy version keeps everything live during the delay — the account still works, the profile is still on the site — and only stops at erasure. It is easier to build and easier to undo, and it means someone who asked to leave is still publicly listed for thirty days on an international biennial’s website. They asked to be gone. They are not gone. The delay was supposed to protect them and it has become a delay imposed on them.
So the split is precise: visibility and access are immediate; only destruction is deferred. From the outside, and from the person’s own point of view, the account is gone the same day. What survives is a row that nobody can see and a link in an email.
The reversal is symmetric:
def rouvrir(user):
"""Annule la clôture et rend tout. Le geste de rétractation."""
user.cloture_demandee_le = None
...
_retirer_du_site_public(user, publier=True)
One link in one email, one click. Not a support ticket, not a form. If the reason for the delay is that most deletions are mistakes, then the recovery has to be cheaper than the mistake was.
Remembering what was published — and only that
Here is the subtle bug that this design avoids, and it is the sort that would otherwise be discovered publicly.
def _retirer_du_site_public(user, publier):
"""Dépublie — ou republie — la fiche d'artiste et ses projets.
Ce qui était EN LIGNE est noté sur le compte, dans `cloture_republier`. La
mémoire appartient à la clôture, pas aux œuvres : deux colonnes de plus sur
`Artist` et `Project` auraient traîné là bien après que la question ne se
pose plus.
Sans cette mémoire, la rétractation republierait des BROUILLONS que
personne n'a relus, sur le site d'une biennale internationale.
"""
Without this memory, the retraction would republish drafts that nobody has reviewed, on the website of an international biennial.
An artist has five projects: three published, two drafts still being written. Closure unpublishes all five. Then they change their mind.
The naive reversal sets is_published = True on everything the account owns —
and puts two unreviewed drafts on the public site of a curated exhibition. Nobody
notices, because the artist was told everything was restored, and the curatorial
team is not watching the pages of an account that just came back.
So the closure records what was actually online:
user.cloture_republier = {
"artistes": [artiste.pk] if artiste.is_published else [],
"projets": [p.pk for p in projets if p.is_published],
}
and the reopening restores exactly that set, then clears the memory.
The placement is argued explicitly, and the argument is good: the memory lives on
the closure (a JSON field on the user), not as a was_published column on
Artist and Project. Two extra columns would be permanent structure serving a
temporary question — present on every row forever, meaningful for a few days in
the life of a few accounts, and a puzzle for the developer who finds them in three
years.
The general rule: state that exists only during a process belongs to the
process, not to the objects the process touches. When the process ends, the
state disappears with it — user.cloture_republier = {} — instead of becoming
schema.
What survives erasure
def effacer(user):
"""L'effacement définitif. Ne demande rien, ne vérifie pas le délai.
Il ne reste ensuite qu'une LIGNE ANONYME — date, motif, rôle. On apprend
pourquoi les gens partent sans garder trace des partants : c'est le seul
usage légitime au regard du RGPD, et il ne suppose aucune donnée
personnelle.
"""
_transmettre_les_projets_collectifs(user)
SuppressionCompte.objects.create(
motif=user.cloture_motif or "",
role=user.role,
clos_le=user.cloture_demandee_le,
)
user.applications.all().delete() # candidatures + pièces (CASCADE)
user.delete()
You learn why people leave without keeping a trace of who left.
The tension is real and common: the organisation genuinely needs to know why artists close their accounts, and the person has exercised their right to erasure.
The resolution is that SuppressionCompte holds a reason, a role and a date —
no foreign key to the user, no identifier, nothing joinable. It is a
statistic, not a record. “Eleven artists left in March, seven citing the
submission process” is answerable; “who was the eleventh” is not, by
construction.
That is the shape to aim for. Not “we anonymise the row” — a nulled-out user row with its dates and its relations intact is often re-identifiable — but a different table, created at deletion time, containing only what the aggregate needs.
And the first line is the one that saves other people’s work:
# Un projet COLLECTIF ne meurt pas avec son porteur : il passe au premier
# associé. Sans cette reprise, l'effacement emporterait en cascade le
# travail de gens qui n'ont rien demandé.
_transmettre_les_projets_collectifs(user)
A collective project does not die with its bearer: it passes to the first associate. Without this handover, the erasure would cascade away the work of people who asked for nothing.
A collective project has several artists and one owner in the schema. CASCADE
on the owner deletes it — and with it, the contributions of three other people who
have not asked to leave and are not consulted.
This is where the right to erasure has a real boundary. One person’s request cannot destroy another person’s data. Ownership transfers before deletion, and the project survives.
The lesson worth extracting is uncomfortable and general: before writing a
deletion, list every object that will cascade and ask, for each one, whether it
belongs to more than one person. In Django this means reading every
on_delete=CASCADE pointing at your user model, and reading it as a sentence
about people rather than rows. The default is CASCADE and the default is a
policy nobody chose.
Note also effacer does not re-check the delay. It is called by the scheduled
job, which is what a_effacer() is for:
def a_effacer(maintenant=None):
"""Les comptes dont le délai de rétractation est écoulé."""
limite = maintenant - timedelta(days=delai_en_jours())
return get_user_model().objects.filter(
cloture_demandee_le__isnull=False, cloture_demandee_le__lte=limite)
Selection and execution are separate: one function decides who, another does what. It makes the selection testable without deleting anything, and it makes the erasure callable by hand for a person who asks to skip the wait — which is a request you will get, and which the law is on the side of.
Finally, the delay itself is a setting, not a constant:
def delai_en_jours():
"""Le délai de rétractation, réglé au tableau de bord."""
return SiteSettings.load().cloture_delai_jours
The right number is a policy question — the organisation’s lawyers and the organisation’s experience of its own users — and it belongs to the person who owns the policy, on a screen, not in a constant that requires a deployment.
What to take away
- Immediate deletion is the wrong default when the data is irreplaceable. Close now, erase later, and recover with one click.
- Defer only the destruction. Access and public visibility must end the same day, or the grace period is imposed on the person rather than offered to them.
- Count what will be lost. An inventory changes minds; a warning does not.
- Translate the confirmation word, ignore accents and case, and fall back to the email. A confirmation nobody can pass is a bug wearing a security badge.
- Record what was public before you unpublish, and put that memory on the process, not on the objects.
- Keep the statistic, destroy the record. A separate table with no join key, not an anonymised row.
- Read every
CASCADEfrom your user model as a sentence about people. One person’s erasure must not take another person’s work. - The delay is policy. Put it on a screen.