Translations

Every string django-mfa shows a user is translatable, and six languages are live: a user whose browser asks for one of them gets django-mfa’s screens in it, with no configuration beyond switching Django’s own i18n on.

What ships

django_mfa/locale/django.pot                     the template
django_mfa/locale/<lang>/LC_MESSAGES/django.po   the source catalog
django_mfa/locale/<lang>/LC_MESSAGES/django.mo   the compiled catalog

de, es, fr, pt_BR, ja, zh_Hans — complete, with no entry left untranslated or fuzzy.

Note

These catalogs were machine-drafted and maintainer-reviewed, not reviewed by a native speaker. They are live because a good translation that reaches users beats a perfect one that never ships — but if something reads wrong to you, that is a bug worth reporting, and a one-line pull request against a .po file is a genuinely welcome contribution.

The .mo files are the half that matters at runtime: Django reads only compiled catalogs. A .po is the source a translator edits; nothing serves it directly.

Using them in your project

Django’s i18n has to be switched on in the host project — django-mfa can’t do it for you:

USE_I18N = True

MIDDLEWARE = [
    ...,
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",     # after sessions
    "django.middleware.common.CommonMiddleware",
]

LocaleMiddleware is what picks a language per request. Without it every request uses LANGUAGE_CODE and per-user language selection does nothing.

Warning

Shadowing a template drops its translations with it. A copy of django_mfa/templates/django_mfa/verify_totp.html in your own app replaces the shipped file and its {% trans %} tags — the strings in your copy are yours to translate, in your project’s own catalog. See Customizing the UI.

Working on the catalogs

One command does everything mechanical:

uv run python tools/compile_catalogs.py

It refreshes the #: source references in every catalog and recompiles every .mo. It never invents, reorders or drops a translation — editing those is the human part. test_i18n.py fails if a .mo is out of date with its .po, so a forgotten run is caught in CI rather than shipped as a translation nobody receives.

This is makemessages + msgfmt reimplemented in Python (django_mfa/tests/support/i18n.py) because gettext’s binaries are a system package this project declines to require — including of its own CI.

Fixing a translation

Edit the msgstr in django_mfa/locale/<lang>/LC_MESSAGES/django.po, run the command above, and commit the .po and .mo together.

Placeholders like %(name)s must appear in the translation exactly as in the source. Get one wrong and interpolation raises KeyError in the middle of somebody’s sign-in — this is the one translation mistake that is an outage rather than an embarrassment. A test enforces it; understanding why matters more than the test does.

Adding a language

Copy django.pot to django_mfa/locale/<lang>/LC_MESSAGES/django.po, set Language: and Plural-Forms: in its header, translate every msgstr, and run the command above.

Getting Plural-Forms right matters more than it looks: it is what selects between msgstr[0] and msgstr[1], so a wrong rule produces fluent text attached to the wrong number. The gettext manual’s table has the correct expression for each language.

A partial catalog is not useful here — a page rendered half in the user’s language and half in English is worse than one rendered wholly in English — and test_i18n.py requires every entry to be translated.

Adding or changing a string

Wrap it: {% trans %} / {% blocktrans %} in a template, gettext_lazy as _ in Python. Then add the entry by hand to django.pot and to each .po, and run the command above. test_i18n.py fails while the catalogs and the code disagree, so this cannot be half-done quietly.

Warning

Give a generic string a context. gettext keys on the string itself, and Django merges every installed app’s catalog into one per language — so a bare {% trans "Save" %} collides with django.contrib.admin’s own "Save", and whichever app INSTALLED_APPS lists first wins the key. Admin is listed first in nearly every project.

The result is invisible: the page renders, in the right language, in another app’s words — and only in the languages that app translates, so reading the English UI will never reveal it. "Remove" was exactly this, silently served as admin’s wording, until it became:

{% trans "Remove" context "second-factor method" %}

test_i18n.py now fails on any bare msgid that a bundled Django app also translates. The fix is always a context, never rewording around admin.

Two things are deliberately not translated: management-command output and system-check messages. Both are read by operators and developers, not end users, and both are matched against by scripts.