Chapter 15 · Internationalization and time zones

15.6 The language views: set_language and JavaScriptCatalog

django/views/i18n.py holds the views a project routes to for its languages: set_language, which stores the language a visitor chose in the language cookie and redirects, and JavaScriptCatalog and JSONCatalog, which send the browser the active language’s catalogue of the "djangojs" domain, with its plural formula and its formats.

They serve a visitor picking a language, and a script in the browser with messages of its own. Neither chooses the language of the request it answers: set_language writes what LocaleMiddleware reads on the next request, and JavaScriptCatalog reads the language already active for this one.

set_language

set_language first decides where to send the visitor, and then, for a "POST", records the choice.

The address is the field "next", of the posted form or else of the query string. It is used only if url_has_allowed_host_and_scheme accepts it for the request’s own host, an "http" address being refused when the request came over HTTPS (The helpers in django/utils/http.py). Otherwise the view tries the Referer header under the same test, and then "/". A request that sent no "next" and whose Accept header does not admit "text/html" skips that search and is answered with a 204; a request with no Accept header admits everything.

On a "POST" only, the view reads the field "language" and checks it with django.utils.translation.check_for_language, which asks whether Django, LOCALE_PATHS or an application has a catalogue of the "django" domain for the code. A code that fails is ignored. For one that passes, the address is put through translate_url, which resolves it in the language active for this request and finds its address in the new one, and the code is set as the cookie (Language prefixes and translated routes). The cookie is made from these settings:

setting default sets the cookie’s
LANGUAGE_COOKIE_NAME default "django_language" sets the cookie’s name
LANGUAGE_COOKIE_AGE default None: neither attribute, and the cookie expires at browser close, as settings.txt docs/ref/ says sets the cookie’s Max-Age and Expires
LANGUAGE_COOKIE_PATH default "/" sets the cookie’s Path
LANGUAGE_COOKIE_DOMAIN default None: no attribute sets the cookie’s Domain
LANGUAGE_COOKIE_SECURE default False sets the cookie’s Secure
LANGUAGE_COOKIE_HTTPONLY default False: a script can read it sets the cookie’s HttpOnly
LANGUAGE_COOKIE_SAMESITE default None: no attribute sets the cookie’s SameSite

A "GET" gets the same redirect, or 204, and nothing more. The docstring gives the reason: the view changes how the visitor will see the rest of the site, so only a "POST" may do it.

django/conf/urls/i18n.py is a URLconf of one pattern for a project to include, "setlang/", named "set_language". The example includes it under "i18n/". A run, with the test client making the requests:

POST /i18n/setlang/ with language=nl, next=/arrivals/
302 Found
Location: /arrivals/
Set-Cookie: django_language=nl; Path=/
Content-Language: en

GET /arrivals/, with that cookie
200 OK
Content-Language: nl

POST /i18n/setlang/ with language=fr, next=/arrivals/
302 Found
Location: /arrivals/
Set-Cookie: django_language=fr; Path=/
Content-Language: en

GET /arrivals/, with that cookie and Accept-Language: nl
200 OK
Content-Language: nl

The response that sets the cookie is still in English: the choice takes effect when LocaleMiddleware reads the cookie on the next request. The last two show that the view’s check is not the middleware’s. check_for_language does not consult LANGUAGES, and Django has a French catalogue, so "fr" is stored; django.utils.translation.get_language_from_request takes a cookie only where it or a variant of it is in LANGUAGES, so it passes over this one and reads the header (Choosing a language: LocaleMiddleware).

JavaScriptCatalog

A script in the browser shows messages too, and Django’s translation functions do not run there. JavaScriptCatalog sends it a script holding the active language’s messages of the "djangojs" domain, which makemessages fills from JavaScript files (Making catalogues: makemessages and compilemessages), and functions that look them up.

The language is the active one, as django.utils.translation.get_language gives it. The domain is the URL’s keyword argument domain=, or else JavaScriptCatalog.domain. The packages come from the keyword argument packages=, names joined by "+", or else from JavaScriptCatalog.packages, which as_view can set; with neither, every installed application is searched. JavaScriptCatalog.get_paths turns each name into its application’s "locale" directory, and raises ValueError for a name that is no installed application’s. The admin’s catalogue is served so: AdminSite.i18n_javascript calls the view with packages=["django.contrib.admin"] (The site’s own pages: the index, the login and AdminSite.each_context).

On every request the view makes a new DjangoTranslation of that language and domain, with those directories as its argument localedirs. It is never the cached translation of the language, and in a domain other than "django" it is made otherwise (Message catalogues: DjangoTranslation):

JavaScriptCatalog.get_catalog walks the translation’s catalogue and then each fallback behind it, keeping the first value found for each key, so the language’s own translations hide those of LANGUAGE_CODE. It leaves out the header. A plural message becomes a list of its forms in order, as many as the header’s nplurals says (JavaScriptCatalog._num_plurals, two without one), with an empty string for a form the catalogue lacks. A message with a context keeps its key, context and message joined by "\x04". JavaScriptCatalog.get_plural takes the expression after plural= in the header’s Plural-Forms line, or None without one, and django.views.i18n.get_formats reads the active language’s formats with get_format (Locale formats: get_format and the format modules).

JavaScriptCatalog.render_to_response writes the catalogue and the formats as JSON into the template "i18n_catalog.js", rendered by a django.template.Engine made with no options, not by the project’s TEMPLATES. In the browser the script keeps everything on an object named django on the global object. Its function pluralidx is the plural expression, written into the script as it stands, with true or false made 1 or 0; without one, only a count of one is singular. Its messages are added key by key to django.catalog, so two catalogues loaded on one page combine. Only the first script loaded defines the formats and the functions gettext, ngettext, gettext_noop, pgettext, npgettext, interpolate and get_format, and puts each on the global object as well. Two parts of it, from a run in Dutch:

GET /jsi18n/, with Accept-Language: nl
  django.pluralidx = function(n) {
    const v = (n != 1);
  ...
  const newcatalog = {
    "%s ship": [
      "%s schip",
      "%s schepen"
    ],
    "Arrivals": "Aankomsten"
  };

JSONCatalog

JSONCatalog is the same view with JSONCatalog.render_to_response replaced: it returns the catalogue, the formats and the plural expression as a JsonResponse, the data without the script. Routed at "jsoni18n/" in a run of the example, it gives this, its formats left out; the office has no German catalogue, and English, the fallback, has none either:

GET /jsoni18n/, with Accept-Language: nl
{"catalog": {"Arrivals": "Aankomsten", "%s ship": ["%s schip", "%s schepen"]}, "plural": "(n != 1)"}
GET /jsoni18n/, with Accept-Language: de
{"catalog": {}, "plural": null}