Email is not a notification channel everywhere.
"""
Le courriel part, et l'école croit avoir prévenu. Au Mali, une bonne part des
candidats ouvre une adresse le jour de l'inscription parce que le formulaire en
demande une, puis ne la relève plus : la boîte reste pleine d'un message qu'ils
n'ont jamais lu, quand ce n'est pas le mot de passe qu'ils ont oublié. Le
téléphone, lui, est allumé. C'est par lui qu'on joint quelqu'un à Ségou la
veille d'une épreuve.
"""
The email is sent, and the school believes it has given notice. In Mali, a good share of candidates open an address on registration day because the form asks for one, and then never check it. The phone is on. That is how you reach someone in Ségou the night before an exam.
This is the SMS layer of the admissions platform of ENI-ABT, and it is a good example of a constraint that no framework abstracts away: 160 characters, one message, and it costs money each time.
Almost every decision in these two modules is about spending a budget that cannot be increased.
Two channels that write different things
"""
Le SMS ne remplace donc pas le courriel : il part **avec** lui. Le courriel
porte le détail — les pièces manquantes nommées une à une, l'adresse du centre,
le motif du rejet ; le SMS porte le fait, et l'adresse où lire le reste. Chacun
des deux suffit à faire venir le candidat ; les deux ensemble font qu'on ne le
perd pas.
"""
The SMS carries the fact; the email carries the detail.
And the code follows: two modules, admissions.courriels and admissions.sms,
not one function with a channel parameter.
"""
Deux modules et non un, parce que les deux canaux n'écrivent pas la même chose
et ne se corrigent pas ensemble — allonger un courriel est gratuit, allonger un
SMS coûte un second message à chaque candidat de la campagne.
"""
Lengthening an email is free; lengthening an SMS costs a second message for every candidate in the campaign.
The usual instinct is a shared notification service with per-channel templates — one place to add a notification type, one place to change wording. It looks like the DRY choice, and it fails on the sentence above: the two channels have different cost functions, so they get edited by different people at different times for different reasons. Sharing the call site means every SMS edit is reviewed by someone thinking about email, and a helpful clarification in a shared string doubles the campaign’s telecom bill.
The rule generalises past SMS: two things that must be changed together belong together; two things whose changes have different costs do not. Duplication is cheaper than a coupling that makes one side’s constraint invisible from the other.
The 160 is not 160
This is the technical heart, and it is a fact many developers meet only in production, on the invoice.
"""
**L'alphabet est ramené à l'ASCII.** Un SMS ne compte 160 caractères que dans
l'alphabet GSM 03.38 ; un seul caractère hors de cet alphabet bascule tout le
message en UCS-2, où la limite tombe à 70 — « é » passerait, « ê » et « ç »
non, et la même phrase tiendrait ou ne tiendrait pas selon le prénom du
candidat.
"""
An SMS holds 160 characters when encoded in the GSM 03.38 7-bit alphabet.
That alphabet is not ASCII and it is not Latin-1. It contains a specific,
historically-negotiated set: the Latin letters, digits, punctuation, and a
handful of accented characters — è é ù ì ò à ä ö ñ ü Ç É Å å Æ æ ß Ø ø, plus
some Greek capitals.
It does not contain ê, â, î, ô, û, ë, ï, or lowercase ç.
One character outside the alphabet forces the whole message into UCS-2, and the limit drops from 160 to 70. Not 160 minus a bit. Less than half.
The consequence named in the docstring is the one that makes this unmanageable
rather than merely annoying: the same sentence fits or does not fit depending on
the candidate’s first name. A template tested with “Amadou” fits. The same
template for “Boubacar Traoré” — no problem, é is in the alphabet. For a
candidate whose name carries ê, the message silently becomes two messages,
arriving out of order, billed twice.
You cannot budget against that. So the module removes the variable:
"""
Plutôt qu'un budget qui dépend du texte, on translittère : « Vous êtes
convoqué » part en « Vous etes convoque ». C'est ce que lisent les Maliens dans
tous leurs SMS, et c'est un budget de caractères qu'on peut tenir de tête.
"""
Transliterate everything to ASCII. The budget becomes a constant a developer can reason about while writing a template, instead of a property of the data.
And the justification for the loss is empirical rather than technical: this is what Malians read in all their SMS. Unaccented French is the normal register of text messaging there. The “degradation” is invisible to the reader and the alternative — a correctly accented message that arrives as two fragments in the wrong order — is worse by every measure.
Worth stating as a principle: prefer a constraint that is constant over one that is merely usually satisfied. A 160-character budget you can hold in your head is worth more than a 160-or-70 budget that is correct on average.
Measuring the budget instead of guessing it
Here is the piece of engineering I most want to point at.
contexte = {
"dossier": dossier,
"site": SITE,
"manque": "",
"jamais_depose": dossier.depose_le is None,
"cloture": dossier.session.cloture_depot if dossier.session_id else None,
}
# Deux rendus, et c'est le prix de la justesse : le premier mesure ce que
# le message coûte sans la liste, le second l'écrit avec ce qui reste. Le
# « + 1 » est l'espace qui précède la clause.
socle = raccourcir(render_to_string("sms/relance.txt", contexte))
contexte["manque"] = _clause_manquantes(dossier, NB_CARACTERES - len(socle) - 1)
return envoyer(
render_to_string("sms/relance.txt", contexte), destinataire, dossier.reference
)
The template is rendered twice. Once with an empty clause, to measure what the fixed part actually costs for this file; then the variable part is built to fit exactly what remains; then the real render.
The reasoning is in _clause_manquantes:
"""
Le budget est mesuré et non deviné : la référence du dossier, le libellé de
la clôture et la formule du brouillon font varier le reste du message d'une
trentaine de caractères, et une constante écrite ici serait fausse pour la
moitié des dossiers.
"""
The budget is measured, not guessed: the file reference, the closing-date label and the draft wording vary the rest of the message by about thirty characters, and a constant written here would be wrong for half the files.
The obvious implementation is MAX_CLAUSE_LENGTH = 60, chosen by looking at one
example. It is right for that example and wrong for the file whose reference is
four characters longer, whose session has a closing date, and which has never been
submitted — three independent variations that stack.
Rendering twice costs a template render. It buys a number that is correct for this message, and it means the template can be edited later without anyone having to remember to adjust a constant in a different file. That last property is the one that survives contact with a real team.
The # + 1 comment is the kind of detail that separates code that works from
code that keeps working: the space between the base and the clause is one
character, it is spent, and it is accounted for. Off-by-one in a character budget
is how a message becomes two.
Degrading in the right direction
noms = [piece.type_piece.libelle for piece in dossier.pieces_manquantes]
if not noms:
return ""
clause = "Il manque : {}.".format(", ".join(noms[:NB_PIECES_CITEES]))
if len(noms) > NB_PIECES_CITEES or len(clause) > budget:
clause = f"Il manque {len(noms)} pièces."
return clause if len(clause) <= budget else ""
Three levels, and the reason for the middle one is the whole point:
"""
Le candidat qui lit « il manque 4 pièces » sait qu'il n'a pas fini et ouvre son
espace pour savoir lesquelles ; celui qui lirait deux noms sur quatre croirait
avoir fait le tour.
"""
Someone who reads “4 documents missing” knows they are not finished and opens their account to find out which; someone reading two names out of four would believe they had covered it.
Partial information is worse than a count. Not less useful — actively worse, because it produces a confident wrong belief. The candidate uploads the two named documents, believes their file is complete, and discovers otherwise when they are not called for the exam.
Note that the fallback to a count triggers on len(noms) > NB_PIECES_CITEES
before any length test. Even with room to spare, three missing documents are
reported as a number rather than as two-of-three. The rule is about honesty, not
about space.
And the last line is the third level: if even the count does not fit, the clause is dropped entirely.
# Le compte lui-même peut ne pas tenir sur un dossier à référence longue et
# clôture datée. Le candidat lit alors qu'il est incomplet, et où regarder :
# c'est moins précis, mais entier.
Less precise, but whole. The message never gets truncated — it loses a clause. A sentence cut at character 160 is what most implementations produce, and it reads as a system failure rather than as a short message.
The ladder, generalised: full detail → an honest summary → nothing. Never a prefix of the detail.
Truncating a name so it still names something
#: Place laissée au lieu de l'épreuve dans la convocation. `Centre.nom` accepte
#: cent quatre-vingts caractères — un seul centre bavard suffirait à pousser la
#: salle et la place hors du message.
NB_CARACTERES_LIEU = 44
#: En deçà, un nom de centre rogné ne se reconnaît plus : la ville seule
#: renseigne mieux que « Lycée Askia Moha… ».
NB_CARACTERES_NOM_LISIBLE = 8
def _lieu(centre):
complet = f"{centre.nom} ({centre.ville})"
if len(complet) <= NB_CARACTERES_LIEU:
return complet
reste = NB_CARACTERES_LIEU - len(centre.ville) - 3
if reste < NB_CARACTERES_NOM_LISIBLE:
return centre.ville[:NB_CARACTERES_LIEU]
return f"{centre.nom[:reste].rstrip()} ({centre.ville})"
Two things are being protected, and the priority between them is explicit.
The room and the seat come after the venue in the message, and they are what you look for on arrival. A verbose centre name would push them out. So the venue gets a hard allocation of 44 characters — a budget, assigned by importance, not a truncation applied when something overflows.
A truncated name must still identify something. NB_CARACTERES_NOM_LISIBLE =
8 is the threshold below which the code stops trying: “Bamako” locates you;
“Lycée Askia Moha…” no longer locates anything. Below eight characters the
remainder is not a shortened name, it is a fragment, and the city alone carries
more information.
.rstrip() on the truncated name is one call and it matters: cutting mid-word
often lands on a space, and "Lycée Askia " (Bamako) reads as a bug where
"Lycée Askia" (Bamako) reads as an abbreviation.
This is what “degrade gracefully” means concretely — not cut it shorter, but decide which of two pieces of information survives, and check that what survives is still meaningful on its own.
What deliberately never goes by SMS
"""
- **le message libre de l'agent** ... Il est rédigé pour un courriel, sa
longueur n'est bornée par rien, et le tronquer à cent soixante signes
donnerait une phrase coupée au milieu — pire qu'un renvoi vers l'écran qui la
porte en entier ;
- **les liens profonds**. Un `https://eniabt.ml/espace/dossier/` mange un
cinquième du message pour aboutir à une page qui demandera une connexion.
`eniabt.ml` suffit : le téléphone le rend cliquable, et le candidat sait où
il va.
"""
Two exclusions, each an argument.
The agent’s free text. A registrar writes “the transcript is illegible, please photograph it again in daylight”. Cut at 160 characters it becomes “the transcript is illegible, please photograph it ag” — which alarms without informing. So the SMS says there is something to read, and where.
Deep links. https://eniabt.ml/espace/dossier/ is 31 characters — a fifth of
the message — to reach a page that will demand a login anyway, at which point the
deep link’s destination is lost to the redirect. eniabt.ml is 9 characters,
phones linkify it, and the candidate knows where they are going.
#: Le site, tel qu'on l'écrit dans un SMS : sans protocole ni chemin. Les
#: téléphones en font un lien, et les huit caractères de « https:// » valent
#: mieux ailleurs dans le message.
SITE = "eniabt.ml"
Eight characters recovered, from noticing that the protocol prefix buys nothing on a device that adds it for you.
Never raising, and never sending by accident
"""
**La règle d'expédition est celle de `core.courriels`, et pour la même raison :
on essaie, on rend vrai ou faux, on ne lève jamais.** L'encaissement est fait,
la convocation est écrite, le statut est changé — perdre ce geste parce qu'une
passerelle de Dakar ne répond pas serait échanger un désagrément contre une
faute.
"""
Losing that action because a gateway in Dakar is not answering would be trading an inconvenience for a fault.
The notification is always the last step of something that already succeeded. A payment has been taken, a convocation written, a status changed. An exception from the SMS gateway propagating up would, at best, show an error for an operation that worked and, at worst, roll it back.
So every send returns a boolean and the caller reports what happened. The distinction being drawn — an undelivered notice is an inconvenience, a lost payment is a fault — is the kind of judgement that has to be made deliberately, because the default behaviour of exceptions is to make everything equally fatal.
Two safety settings complete it:
"""
**Le canal s'allume à la main.** `SMS_ACTIF` est faux par défaut : un jeu
d'essai qui part sur de vrais téléphones, ce sont de vrais candidats prévenus
d'une épreuve qui n'existe pas. Éteint, le message est journalisé et rien ne
quitte le serveur.
"""
Off by default, and the reason is stated in terms of the human outcome: real candidates told about an exam that does not exist. A test fixture with real phone numbers is not a data problem, it is several thousand people travelling.
"""
**Le Mali seulement, sauf réglage contraire.** L'identifiant d'expéditeur ANW
est déclaré chez les opérateurs maliens ; ailleurs, le message part au mieux
sous un numéro inconnu, au pire pas du tout — et il est facturé dans les deux
cas.
"""
A registered sender ID works with the operators it is registered with. Outside that set the message arrives from an unknown number — which, for a message about an exam, is indistinguishable from a scam — or does not arrive at all. Billed either way. So the country prefixes are an allow-list, and a diaspora number falls back to email, which is the right channel for someone who is not in the country anyway.
And the gateway host is pinned:
#: Point d'entrée de la passerelle. Figé comme celui de la banque d'images : ce
#: module ne parle qu'à cette adresse, et un hôte venu d'ailleurs serait une
#: requête sortante que personne n'a demandée.
PASSERELLE = "https://lamsms.lafricamobile.com"
A settings-configurable base URL for an outbound HTTP client is a server-side-request-forgery primitive waiting for a configuration mistake. The override exists for vendor acceptance testing and the comment says so, which is the difference between a hard-coded constant and a hard-coded constant somebody will “fix” into a setting.
The credit check
#: Solde du compte, rendu en XML. Sert la commande `credit_sms` : une campagne
#: de convocations qui s'arrête à mi-chemin faute de crédit se voit à
#: l'arrivée — c'est-à-dire trop tard.
CHEMIN_CREDIT = "/credits"
A convocation campaign that stops halfway for lack of credit is noticed on arrival — that is, too late.
Sending four thousand convocations is not idempotent from the candidate’s point of view: the ones who received theirs travel, and the ones who did not, do not. A balance check before the campaign is one HTTP call, and the alternative is discovering the shortfall from the people standing outside the exam hall.
Any bulk operation against a metered external service needs a “do I have enough?” check before it starts. The failure of a metered API is not an error you retry; it is a partial outcome you cannot undo.
What to take away
- Choose the channel from how people actually live. An address opened once to satisfy a form is not a channel.
- Split channels whose changes have different costs, even at the price of duplication.
- A single non-GSM-03.38 character halves your SMS to 70 characters. Transliterate to ASCII and get a budget you can reason about.
- Measure the budget by rendering twice, rather than hard-coding a constant that is wrong for half your data.
- Degrade full detail → honest summary → nothing. Never a prefix: partial information produces confident wrong beliefs.
- Allocate space by importance, and set a threshold below which a truncated value stops identifying anything.
- Notifications return booleans. They are the last step of something that already succeeded.
- Default the outbound channel off, restrict it to where your sender ID is registered, and pin the gateway host.