Translation rules (i18n)¶
Status: Implemented Last reviewed: 2026-04-18 Sources of truth:
config/settings/base.py(LANGUAGE_CODE,LANGUAGES,LOCALE_PATHS);Makefile(i18n-extract,i18n-compile,i18n-update);locale/fr/LC_MESSAGES/django.po.
Scope¶
How to internationalise user-facing strings in Aletheia: the source language convention, when to use which translation function, the safe placeholder pattern, the workflow for extracting / compiling translations, and the antipatterns that silently break translation.
This file is the single source of truth. The earlier dual-convention regime (English source for models, French source for templates/views) is retired as of 2026-04-18 — it drifted in practice and was hard to enforce. See "History and rationale" at the bottom.
The hard rule¶
All user-facing source strings are written in French. Templates, view messages, model verbose_name/help_text/choices, form labels, admin labels, exception messages — French source, always. Wrapped in _() / gettext_lazy / {% trans %} so the catalogue extractor sees them.
Code identifiers stay English: field names (first_name, email), class names (Patient, Practice), choice constants (STATUS_ACTIVE = 'active'), method names, URL names, log messages, comments, docstrings. Only the string passed to _() is French.
class Patient(models.Model):
STATUS_ACTIVE = 'active' # English identifier — kept
STATUS_CHOICES = [
(STATUS_ACTIVE, _('Actif')), # French source — user-visible
]
first_name = models.CharField(_('Prénom'), max_length=100)
# English field name (kept) ↑ French verbose_name
email = models.EmailField(_('Adresse email'))
class Meta:
verbose_name = _('Patient')
verbose_name_plural = _('Patients')
Why the LANGUAGE_CODE='nl-be' trick¶
config/settings/base.py:142 sets LANGUAGE_CODE = 'nl-be'. There is no nl-be catalogue. Django's lookup falls through, and the user sees the raw source string — which under this convention is French. So if French text appears in the UI, the rule is being followed. If English text appears in the UI, it is a bug — either a missed {% trans %} / _() wrap, or a stale English source string that hasn't been migrated to French yet.
This trick is the project's primary visual enforcement signal. Never change LANGUAGE_CODE to 'fr' — that would silence the bug indicator. (Also documented in CLAUDE.md "Gotchas".)
Which translation function to use¶
| Context | Import | Use |
|---|---|---|
| Templates | {% load i18n %} |
{% trans "Texte" %} for inline; {% blocktrans %}Bonjour {{ user }}{% endblocktrans %} when the string contains template variables. |
| Model fields, choices, Meta | from django.utils.translation import gettext_lazy as _ |
_("Texte"). Always lazy — model class bodies execute at import time, before any request locale is set. Eager gettext would resolve too early. |
| Forms (labels, help_text, error_messages) | gettext_lazy as _ |
Same lazy reasoning — form classes are also defined at import time. |
Admin (short_description, action labels, list_display) |
gettext_lazy as _ |
Same. |
View messages (messages.success, messages.error) |
from django.utils.translation import gettext_lazy as _ |
Lazy is the safer default — works whether the message is computed inside the view or held as a module-level constant. |
ValidationError / ValueError raised in views or form clean_* |
gettext_lazy as _ |
Same. |
| JavaScript (form validation messages, char-counter labels) | <body data-i18n-required="..."> etc. |
The translated value goes into a data-* attribute server-side via {% trans %}; JS reads it. See guidelines/ui/forms.md "JS conventions". |
Convention: import gettext_lazy aliased to _. The eager gettext is rarely needed in this codebase — favour lazy by default.
Format strings (variables in messages)¶
Two safe patterns. Pick percent-format unless you have a reason — it's the convention used in the canonical reference (apps/imports/views.py:GLTrialBalanceImportView).
Percent-format (canonical):
_("Synchronisation réussie pour %(name)s.") % {'name': practice.display_name}
_("Erreur lors du lancement de l'import : %(error)s") % {'error': str(e)}
.format()-style (acceptable):
Templates with variables:
{% blocktrans with name=practice.display_name %}Synchronisation réussie pour {{ name }}.{% endblocktrans %}
Use consistent placeholder names across messages so semantically-identical strings collapse to the same msgid in the .po file. E.g. always use %(name)s for entity names, %(count)s for counts, %(error)s for exception strings — not a mix of %(practice_name)s, %(dentist_name)s, %(n)s.
Repeated messages — module-level constants¶
When the same translatable string appears in multiple call sites (e.g. four import views all show the same "Import lancé" success toast), define a module-level constant at the top of the file. This both deduplicates and makes the intent obvious.
# apps/imports/views.py
from django.utils.translation import gettext_lazy as _
IMPORT_STARTED_MSG = _("Import lancé avec succès ! Vous pouvez suivre sa progression en temps réel.")
IMPORT_ERROR_MSG = _("Erreur lors du lancement de l'import : %(error)s")
class DentistImportView(...):
def form_valid(self, form):
try:
...
messages.add_message(self.request, messages.INFO, IMPORT_STARTED_MSG, extra_tags='toast info')
except Exception as e:
messages.error(self.request, IMPORT_ERROR_MSG % {'error': str(e)})
Single-use messages stay inline — no constant needed.
Antipatterns¶
_(f"…") — silently never translates¶
Never wrap an f-string with _(). The f-string is interpolated into a runtime-unique string before _() runs, so the lookup key for the catalogue includes the interpolated values and never matches any msgid. The translation looks set up correctly but the .po file is bypassed entirely.
# ❌ WRONG — looks translated, never resolves at runtime
messages.success(request, _(f"Synchronisation réussie pour {practice.display_name}."))
# ✅ RIGHT — placeholder in the source string, interpolation after _()
messages.success(request, _("Synchronisation réussie pour %(name)s.") % {'name': practice.display_name})
This bug was found in 10 sites in apps/sync/views.py during the 2026-04 audit and fixed in commit 8a023fd. Easy to introduce, easy to miss in review — encode in the enforcement hook.
Using gettext (eager) in a model class body¶
# ❌ WRONG — evaluated at module import, before any request locale is set
from django.utils.translation import gettext as _
class MyModel(models.Model):
STATUS_CHOICES = [('active', _('Actif'))]
# ✅ RIGHT — evaluated lazily, when the value is actually rendered
from django.utils.translation import gettext_lazy as _
Concatenation instead of placeholders¶
# ❌ WRONG — translators can't reorder words
msg = _("Utilisateur") + " " + username + " " + _("créé")
# ✅ RIGHT — single string with placeholder
msg = _("Utilisateur %(username)s créé") % {'username': username}
Hardcoded text in templates¶
{# ❌ WRONG — won't appear in .po, won't translate, will look out of place if the source is ever changed #}
<button>Enregistrer</button>
{# ✅ RIGHT — visible in .po, swappable, audit-checkable #}
<button>{% trans "Enregistrer" %}</button>
The nl-be trick still surfaces this visually in dev — but the enforcement hook should catch it before commit.
English source string in code¶
# ❌ WRONG (under the new convention) — surfaces as English in the UI
verbose_name = _('email address')
# ✅ RIGHT — French source
verbose_name = _('Adresse email')
The 304 model-level English source strings tracked in roadmap/backlog/i18n-model-source-strings.md are exactly this — being repurposed as a "rewrite source to French" sweep.
Workflow¶
When you add or change a translatable string:
- Write the source in French wrapped in
_()/{% trans %}. - Run
make i18n-extract— scans the codebase and updateslocale/fr/LC_MESSAGES/django.powith new msgids. With French source, msgids are French; the correspondingmsgstrstays empty (Django falls back to the msgid, which IS the French source — no work needed). - Run
make i18n-compile— compiles.po→.mofor runtime. Required after any extract. - Restart the web container if your change affects model-level strings (verbose_name etc.) —
make restart(ormake restart ENV=staging). - Visual check: load the page; if the new string appears in French, you're done. If it appears in English (or as the
_()raw call), something is wrong — likely a missed_()wrap or a stale English source.
Adding English support later (currently not a target) would be: write English msgstrs in the locale/en/LC_MESSAGES/django.po file (which doesn't exist yet — would need make i18n-extract with -l en).
Note on make i18n-update: the target wraps i18n-extract + scripts/translate_to_french.py + i18n-compile. The middle step runs a manual EN→FR dictionary that filled empty msgstrs — useful only under the old dual convention. Under French-source, that script does nothing useful. Either drop the script and simplify the target, or repurpose it for FR→EN if/when English UX is added. Tracked as a follow-up in the i18n model-source-strings backlog item.
File locations¶
- Per-language catalogue:
locale/fr/LC_MESSAGES/django.po(project-wide). App-specific catalogues can live atapps/myapp/locale/fr/LC_MESSAGES/django.pobut the project currently uses one global catalogue. - Compiled binary:
locale/fr/LC_MESSAGES/django.mo— generated bymake i18n-compile, never hand-edited. - Settings:
config/settings/base.py—LANGUAGE_CODE,LANGUAGES,LOCALE_PATHS,MIDDLEWARE(must includeLocaleMiddleware).
Known deviations¶
The 2026-04-18 French-source sweep (commits e7d9379…2582ee3, 13 commits)
closed the two backlog items that previously lived here. Full execution
record with per-app counts, lessons learned, and how-to-audit-future-drift
instructions: roadmap/done/i18n-french-source-convergence-2026-04.md.
- Model-level English source strings (was 304, now 0 against the curated
dictionary) — see
roadmap/done/i18n-model-source-strings.md(original backlog plan, superseded). - Templates and views drift to English source — see
roadmap/done/i18n-templates-views-french-source-sweep-2026-04.md(original backlog plan, superseded).
Tooling artefacts updated in the same sweep:
- scripts/translate_to_french.py deleted (the EN→.po fill workflow it drove
is retired). The curated EN→FR dictionary now lives inline in
scripts/audit_i18n_source.py as the audit's "definitely English" lookup.
- Makefile i18n-update simplified to i18n-extract + i18n-compile
(no more dictionary-fill step).
Remaining drift after the sweep is concentrated in three areas — none rise
to "must fix before next release" but should be picked up opportunistically:
- apps/finance_workflow/models.py — newer module, inherited English source.
- apps/websites/models.py — multilingual content fields + Helios-facing
schema labels.
- A handful of low-visibility admin fieldset titles and form labels in
apps/register/admin.py, apps/crm/, apps/core/models.py
(ContractDocument file fields).
scripts/audit_i18n_source.py will surface these on each run; treat its
"suspected-english" bucket as the rolling backlog.
When to break the rules¶
- Code identifiers and constants — always English.
STATUS_ACTIVE = 'active',OWNERSHIP_TYPE_FULL,migrate_holders— these are stable keys that DB schemas, URL routes, and other code reference. Only the_()-wrapped display of these (_('Actif'),_('Propriété pleine')) is French. - Internal log messages — English by convention. Logs are read by developers (English-speaking by professional norm), not users. Wrap user-shown strings in
_(); leavelogger.info("Importing %s rows", count)alone. - Code comments and docstrings — English by convention (matches Python/Django ecosystem norms).
- Tests — assertion messages, fixture strings, pytest IDs are English (test code is dev-facing).
- Third-party library labels — if the library doesn't support i18n or supplies English-only labels, accept the bug and consider a future wrapper.
History and rationale¶
The original convention was dual-source: English for models (lazy-translated to French via .po + scripts/translate_to_french.py), French for templates and view messages (no .po needed because nl-be falls through to source). The split came from "code in English" being industry-standard while the product is French-only.
In practice the dual convention drifted hard:
- Templates and view messages occasionally drifted to English (devs defaulting to industry standard).
- Models that should have been translated stayed English (304 strings tracked, never finished).
- The nl-be trick served two contradictory purposes (catching missed {% trans %} AND catching un-translated model strings) which made enforcement fuzzy.
After review on 2026-04-18, Option B (consolidate to French source everywhere) was chosen. Reasoning: less total churn than going all-English (rewriting hundreds of French template strings), aligns source with product language, sharpens the nl-be trick to a single rule (any English in UI = bug), reduces enforcement to a 5-line regex, collapses two backlog items into one. The cost — losing English-source future-proofing for international expansion — is YAGNI: the product is internal Groupe Suffren, all contributors are French, and FR→EN in .po is the standard pattern for non-Anglo-primary apps if it's ever needed.
Future enforcement (informational)¶
When the proactive enforcement hook lands (step 3 of guidelines/README.md roadmap), the i18n checks should include:
- Hard, regex-deterministic: any _(\s*f" is a violation. Easy.
- Hard: any visible text in templates/**/*.html outside {% trans %} / {% blocktrans %}. Existing rule.
- Heuristic: any _("…") whose argument is detectable as English (3+ ASCII-only Latin words, no French diacritics, matches a curated dictionary like the one in scripts/translate_to_french.py). Repurpose that script's dictionary as the audit's English-detection list. Will have false positives on French-without-accents strings ("Patient", "Cabinet", "Code") — handle with a whitelist.
- Soft: any messages.*() call whose string isn't _()-wrapped. Easy enough to grep.
The hook should be quiet on the parts of the codebase already French-source and noisy on the drift areas — until the sweeps land, then quiet everywhere.