Internationalization (i18n)¶
EN + DE. Topic 5 lock-in.
Confidence: High — strategy and locale-source rule are both locked (rule locked 2026-04-27).
Two layers, two strategies¶
| Layer | Mechanism | Source of truth for locale |
|---|---|---|
| UI strings (page labels, buttons, validation messages) | Babel + Jinja2 {% trans %} |
Signed-in stringer: Stringer.default_locale. Signed-in V3 client: Person.default_locale (the Person-bound session per ADR-0006 (in-flight)). Unauthenticated request: browser Accept-Language (fallback). |
| PDF receipts | One template file per language (receipt_en.html, receipt_de.html) |
order.client_profile.person.default_locale else Stringer.default_locale |
Two layers because they have genuinely different audiences:
- UI is read by stringers (and admin Stefan). Probably DE for some Swiss stringers, EN for others.
- Receipts are read by clients — who are not necessarily the stringers. A German-speaking stringer with an English-speaking client should still send their client an English receipt.
UI string layer: Babel + Jinja2¶
| Aspect | Choice |
|---|---|
| Library | Babel (the de-facto Python i18n toolkit) |
| Template integration | Jinja2's {% trans %} block + gettext() (_() in code) |
| Catalogue files | locales/en/LC_MESSAGES/messages.po + messages.mo (compiled), locales/de/... |
| Build step | pybabel extract + pybabel update + pybabel compile — automated in the keystone CI template |
| Locale negotiation | Per-request middleware sets the locale from the locked rule (below) |
UI locale source: locked rule (2026-04-27)¶
The rule resolves the case where a stringer's account default_locale is DE but the browser sends Accept-Language: en (or vice versa). Saved account preference wins.
| Request | Locale source |
|---|---|
| Signed-in stringer (V2) | Stringer.default_locale (EN or DE). Browser Accept-Language is ignored for authenticated stringers. |
| Signed-in client (V3 client portal) | Person.default_locale. Browser Accept-Language is ignored for authenticated clients. |
| Unauthenticated request (e.g. public listing page, public receipt link if any) | Browser Accept-Language is the fallback, negotiated against [en, de] with en as the default if no match. |
Account record: the stringer's account row carries a default_locale field (enum: en | de). V3 clients carry the same field on the Person record (per ADR-0004 — Person is the platform-level identity row that the V3 client-portal session binds to).
Why account-preference-wins: stringers (and V3 clients) on a borrowed device, a colleague's laptop, or a freshly installed phone get the language they configured, not whatever the browser default happens to be. The setting is one place to change, predictable, and survives device-switch — which is in line with the "same user, two devices, same time" V2 mode in the requirements.
Override: none in V2. A per-session toggle (cookie-backed) remains a single-PR additive change later if it's ever asked for; it would not change the rule above, only layer on top.
Receipt language: locked rule¶
Already pinned in receipts:
The locale field lives on Person (the platform-level identity row per ADR-0004). A Person carries one default_locale regardless of how many stringers serve them; that locale wins for any receipt addressed to them.
New-Person default-locale seeding (locked 2026-05-04): when a stringer creates a new Person (e.g. via the Add-Stringjob flow when a player is being entered for the first time), Person.default_locale is seeded from the creating stringer's Stringer.default_locale. This is a one-time write at Person-creation. The Person can later set their own preference (via the V3 client portal), at which point that Person-level value wins per the resolution rule above. The seeding rule keeps the V2 single-locale stringer experience friction-free (a German-speaking stringer with mostly German-speaking clients doesn't have to pick a locale per Person) while leaving the V3 client-self-service path open.
Per-order override is not in V2. If Stefan ever wants it, it's a single column on orders (receipt_locale_override) — small, additive, no migration risk.
Catalogue / data localization (NOT done)¶
We do not translate user-entered data:
- Racket models, string SKUs, comments, player names — stored as-is, displayed as-is.
- BYO toggles, status badges — these are translated UI, not data.
Translating user-entered data is a different beast (per-row translation tables, content moderation, fallback chains). Out of scope for V2 and V3.
Adding a third language later¶
If a French- or Italian-speaking stringer joins:
- Add
Stringer.default_localeenum valuefr(orit). - Add
Person.default_localeenum value. - Add
locales/fr/LC_MESSAGES/messages.po—pybabel init -l fr, translate, compile. - Add
templates/receipts/receipt_fr.html— copyreceipt_en.html, translate the strings. - Done.
The architecture deliberately makes "add a language" an additive change with no schema migration on existing data. Existing stringers/Persons keep their current locale.
Build and deploy implications¶
- Babel
.mocompile is part of the Docker image build. Stale.pofiles would silently ship the wrong strings — the build step asserts no fuzzy translations remain in production builds. - Translation reviews are a manual deliverable. At 2 languages × ~a few hundred UI strings, this is a one-afternoon task at V2 ship time and a small ongoing cost as features land.
What this chapter does NOT cover¶
- Number/currency formatting (
fr_CHvsde_CHfor CHF) — Babel'sformat_currency()handles it; not architecturally significant. - Date format localization — same, Babel handles.
- RTL languages — out of scope; would need CSS adjustments.