Backend i18n¶
Django gettext for translation markers, Babel for locale-aware formatting, and Jinja2 filters for templates.
Translation Markers¶
gettext_lazy — Models, Choices, Forms¶
Use deferred evaluation for strings that are evaluated at import time:
from django.utils.translation import gettext_lazy as _
class LoanStatus(models.TextChoices):
ACTIVE = "active", _("Active")
CLOSED = "closed", _("Closed")
class Loan(BaseModel):
class Meta:
verbose_name = _("loan")
verbose_name_plural = _("loans")
gettext — Services, Tasks, Runtime Errors¶
Use immediate evaluation for strings evaluated at runtime:
from django.utils.translation import gettext as _
raise DoNotContactError(_("Borrower has do_not_contact flag set."))
raise DoNotContactError(
_("Borrower has active do-not-interact rule for channel '%(channel)s'.")
% {"channel": channel}
)
Never use f-strings inside _()
The message extractor cannot parse f-strings. Always use % named substitution:
What NOT to Mark¶
| Category | Reason |
|---|---|
| GL entry descriptions | Audit trail — must stay in English |
Log messages (logger.info/warning/error) |
For developers, not users |
| Webhook payloads and external API data | Consumed by machines |
| Internal identifiers and code-level strings | Not user-facing |
Model Conventions¶
verbose_nameshould be lowercase (Django capitalizes automatically)- Abstract base models (mixins) do not get
verbose_name - Use
gettext_lazyfor allhelp_texton model fields
Message Catalogs¶
File Locations¶
backend/locale/
├── en/
│ └── LC_MESSAGES/
│ ├── django.po # Source catalog (English)
│ └── django.mo # Compiled catalog
└── es/
└── LC_MESSAGES/
├── django.po # Source catalog (Spanish)
└── django.mo # Compiled catalog
Workflow¶
# Extract translatable strings from source code
uv run python manage.py makemessages -l en -l es --no-wrap
# After translating .po files, compile to .mo
uv run python manage.py compilemessages
Both .po and .mo files are committed to the repository.
Babel Formatters¶
common/formatting.py provides locale-aware formatting for server-rendered content (PDFs, emails, compliance documents):
| Function | Input | Output (locale=en) |
Output (locale=es) |
|---|---|---|---|
fmt_currency(1234.56, "USD") |
Decimal | $1,234.56 |
1.234,56 US$ |
fmt_date(date(2025,1,15)) |
date | Jan 15, 2025 |
15 ene 2025 |
fmt_datetime(dt) |
datetime | Jan 15, 2025, 2:30:00 PM |
15 ene 2025, 14:30:00 |
fmt_number(1234.56) |
Decimal | 1,234.56 |
1.234,56 |
fmt_percent(0.065) |
Decimal | 6.5% |
6,5% |
All functions accept a locale: str = "en" parameter. They are pure functions with no Django dependency.
Jinja2 Template Filters¶
Formatting functions are registered as Jinja2 filters on two template environments:
PDF Templates (common/pdf.py)¶
_env.filters["currency"] = lambda v, locale="en", currency="USD": fmt_currency(v, currency=currency, locale=locale)
_env.filters["date"] = lambda v, locale="en": fmt_date(v, locale=locale)
_env.filters["number"] = lambda v, locale="en": fmt_number(v, locale=locale)
_env.filters["percent"] = lambda v, locale="en": fmt_percent(v, locale=locale)
Communication Templates (apps/communications/services.py)¶
def _jinja2_env(*, locale: str = "en", currency: str = "USD") -> jinja2.Environment:
env = jinja2.Environment(autoescape=True)
env.filters["currency"] = lambda v, loc=locale: fmt_currency(v, currency=currency, locale=loc)
env.filters["date"] = lambda v, loc=locale: fmt_date(v, locale=loc)
env.filters["number"] = lambda v, loc=locale: fmt_number(v, locale=loc)
env.filters["percent"] = lambda v, loc=locale: fmt_percent(v, locale=loc)
return env
Usage in Templates¶
Dear {{ borrower.first_name }},
Your payment of {{ amount | currency }} is due on {{ due_date | date }}.
Your current interest rate is {{ rate | percent }}.
The locale parameter is passed through from the borrower's language preference.
See Also¶
- Overview --- i18n architecture
- Localization --- Per-tenant settings, template resolution
- Adding a Language --- Step-by-step guide