Translations (i18n)¶
The site is internationalized with Django's standard gettext machinery. The interface, flash
messages, form/validation text, the admin, and the transactional emails (login code, e-mail
verification) are all translatable. Talk content imported from Pretalx (titles, abstracts, speaker
names) is data, not UI, and is shown as authored.
Languages¶
Offered languages live in settings.LANGUAGES:
| Code | Language | Catalog |
|---|---|---|
en |
English (source) | none (source strings) |
pt-br |
Portuguese (Brazil) | locale/pt_BR/LC_MESSAGES/django.{po,mo} |
de |
German | locale/de/LC_MESSAGES/django.{po,mo} |
es |
Spanish | locale/es/LC_MESSAGES/django.{po,mo} |
The on-disk locale directory uses Django's locale name (e.g. pt_BR, de, es); the language code
used everywhere else is lowercase and hyphenated (e.g. pt-br). German uses the formal register and
Spanish a neutral/formal register, matching Django's own bundled catalogs.
How the active language is chosen¶
django.middleware.locale.LocaleMiddleware resolves the language per request from, in order:
- the
django_languagecookie (set by the language switcher), then - the
Accept-Languagerequest header, then settings.LANGUAGE_CODE(the default).
On top of that, users.middleware.UserLanguageMiddleware runs after authentication and, for a
logged-in user who has set CustomUser.preferred_language, overrides the choice so it follows the
account across browsers and devices. Transactional emails are rendered in the recipient's
preferred_language when set (see users.adapters.AccountAdapter.send_mail).
The switcher itself is templates/partials/language_selector.html in the nav. It POSTs to the
custom set_language view (users.views.set_language), which wraps Django's set_language to also
persist the choice on the user's profile.
Marking strings for translation¶
Templates - load the tag library once per file and wrap visible text:
{% load i18n %}
<h1>{% trans "Schedule" %}</h1>
<p>{% blocktrans with name=event.name %}Welcome to {{ name }}{% endblocktrans %}</p>
Use {% blocktrans %} whenever the string contains a variable. Do not wrap dynamic data (talk
titles, speaker names) or markup attributes that are not user-visible text.
Python - use gettext_lazy for module/class-level strings (model fields, form fields, admin
labels) and gettext for per-request strings (messages in views). Use printf placeholders, never
f-strings, when a translatable string interpolates a value:
from django.utils.translation import gettext as _
messages.success(request, _("Saved %(n)s talks") % {"n": count})
Do not translate log messages or developer-only exceptions.
Workflow¶
After changing or adding translatable strings, regenerate and compile the catalog (requires GNU
gettext; on macOS brew install gettext):
# 1. Extract strings into locale/pt_BR/LC_MESSAGES/django.po (preserves existing translations)
uv run python manage.py makemessages -l pt_BR \
--ignore=".venv/*" --ignore="staticfiles/*" --ignore="media/*" --ignore="docs/*"
# 2. Edit django.po and fill in the new msgstr entries
# 3. Compile to django.mo (what Django reads at runtime)
uv run python manage.py compilemessages -l pt_BR
Both django.po (source) and django.mo (compiled) are committed. The Docker image copies the
locale/ directory into the final stage, so the compiled catalog ships with the app. Re-run
compilemessages whenever you edit the .po or the change will not take effect.
Adding a new language¶
- Add it to
settings.LANGUAGES, e.g.("de", _("German")). uv run python manage.py makemessages -l de(locale name, e.g.de,fr,pt_BR).- Translate
locale/<locale>/LC_MESSAGES/django.po. uv run python manage.py compilemessages -l de.- The switcher and
CustomUser.preferred_languagechoices pick it up automatically fromsettings.LANGUAGES.