Skip to content

Localization

Per-tenant settings for locale, timezone, and currency. Server-side formatting for documents and communications.

Per-Tenant Settings

Each tenant configures its localization preferences:

Field Type Default Example
locale SupportedLocale en es
timezone TimeZoneField UTC America/New_York
default_currency SupportedCurrency USD MXN

TenantLocaleMiddleware

Placed after TenantStatusMiddleware, this middleware activates locale settings per-request.

Language Resolution Priority

  1. Accept-Language header — parsed for quality scores (e.g., es;q=0.9), matched against LANGUAGES
  2. Tenant locale — the tenant's configured default language
  3. LANGUAGE_CODE — Django setting fallback (en-us)

The middleware:

  • Attempts exact language match, then base language (e.g., es-mxes)
  • Activates translation.activate() and timezone.activate()
  • Sets request attributes: request.locale, request.tenant_timezone, request.tenant_currency
  • Sets the Content-Language response header
  • Deactivates locale and timezone after response

Timezone Handling

  • Accepts both zoneinfo.ZoneInfo objects and IANA timezone strings
  • Converts strings to ZoneInfo objects internally
  • Defaults to UTC on any error

Server-Side Formatting

Server-side formatting applies only to rendered documents — PDFs, emails, and compliance reports. API responses always return raw values.

When Formatting Applies

Output Formatted? Technology
API JSON responses No — raw Decimal, ISO dates Frontend formats via Intl
PDF documents Yes Babel + Jinja2 filters
Email/SMS templates Yes Babel + Jinja2 filters
Compliance reports Yes Babel formatters

Babel Formatters

common/formatting.py wraps Babel for locale-aware formatting:

from common.formatting import fmt_currency, fmt_date

# English
fmt_currency(Decimal("1234.56"), "USD", locale="en")  # → "$1,234.56"

# Spanish
fmt_currency(Decimal("1234.56"), "USD", locale="es")  # → "1.234,56 US$"

Communication Template Resolution

When sending a communication, the system resolves the template language:

async def resolve_template_for_language(
    *,
    template_type: str,
    channel: str,
    program_id: UUID | None,
    language: str,
) -> CommunicationTemplate | None:

Resolution order:

  1. Find template matching (template_type, channel, program_id, language)
  2. If not found and language != "en", fall back to English
  3. Return None if no template exists

Template Language Field

CommunicationTemplate has a language field with a unique constraint:

UniqueConstraint(
    fields=["template_type", "channel", "program", "language"],
    name="unique_template_per_language",
)

This ensures one template per type/channel/program/language combination.

Sending with Language Preference

send_communication() reads borrower.language_preference to select the template:

borrower_lang = getattr(borrower, "language_preference", "en") or "en"
rendered = await render_template(
    template_id=template_id,
    context={"borrower": borrower},
    locale=borrower_lang,
)

PDF Template Resolution

render_pdf() tries language-specific template variants:

render_pdf("monthly_statement.html", context, language="es")
# Tries: monthly_statement_es.html → monthly_statement.html

Existing Template Variants

Template English Spanish
Monthly statement monthly_statement.html monthly_statement_es.html
TILA disclosure tila_disclosure.html tila_disclosure_es.html
Adverse action notice adverse_action_notice.html adverse_action_notice_es.html

Template variants follow the naming convention {stem}_{language_code}{suffix}.

Language Source

PDF generation reads from borrower.language_preference:

language = getattr(borrower, "language_preference", "en") or "en"
pdf_bytes = render_pdf("monthly_statement.html", context, language=language)

See Also