Aller au contenu

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):

_("Synchronisation réussie pour {name}.").format(name=practice.display_name)

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:

  1. Write the source in French wrapped in _() / {% trans %}.
  2. Run make i18n-extract — scans the codebase and updates locale/fr/LC_MESSAGES/django.po with new msgids. With French source, msgids are French; the corresponding msgstr stays empty (Django falls back to the msgid, which IS the French source — no work needed).
  3. Run make i18n-compile — compiles .po.mo for runtime. Required after any extract.
  4. Restart the web container if your change affects model-level strings (verbose_name etc.) — make restart (or make restart ENV=staging).
  5. 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 at apps/myapp/locale/fr/LC_MESSAGES/django.po but the project currently uses one global catalogue.
  • Compiled binary: locale/fr/LC_MESSAGES/django.mo — generated by make i18n-compile, never hand-edited.
  • Settings: config/settings/base.pyLANGUAGE_CODE, LANGUAGES, LOCALE_PATHS, MIDDLEWARE (must include LocaleMiddleware).

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 _(); leave logger.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.