Page language: without an offered profile language the browser decides, past the offered list and the instance default #398

Closed
opened 2026-10-01 10:46:24 +00:00 by tiagoagueda · 1 comment
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the locale middleware against the Configuration page of the wiki and requesting pages with different Accept-Language headers through the test client.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at core/site.py:213-232 (default_language(), offered_languages() and offers()), accounts/forms.py:202 (the Use the instance default label) and accounts/forms.py:154 (the docstring of language_choices()); the middleware, the settings and the test have not moved, and locale/status.json still shows Swahili and Arabic at 0 of 1873.

A profile's language is applied only when the instance offers it. In every other case (a profile language that has been withdrawn, a profile set to Use the instance default, a visitor who is not signed in) the page keeps whatever Django's LocaleMiddleware negotiated from the language cookie or the browser's Accept-Language, against all 69 entries of LANGUAGES, falling back to en-gb. Neither the offered list nor the instance's default language is consulted. A withdrawn language keeps being served to everybody whose browser asks for it, the instance default is never used, and a language whose catalogue nobody has begun is declared on pages whose text is English.

  • Where:
    • core/middleware.py:135-143 (UserPreferencesMiddleware.__call__): if language and not self._offered(language): language = "", then if language: translation.activate(language), with no other branch. The choice made earlier by django.middleware.locale.LocaleMiddleware (config/settings/base.py:95) stands.
    • config/settings/base.py:450-455: LANGUAGE_CODE = "en-gb" and LANGUAGES = languages.LANGUAGES, the only list LocaleMiddleware knows.
    • core/site.py:251-270: default_language(), offered_languages() and offers() are read by documents, notifications and the picker, and by the middleware only to reject a profile language.
    • accounts/forms.py:201: the empty choice is labelled Use the instance default, while the docstring of language_choices() (accounts/forms.py:153) calls it "an option to follow the browser". The code does the second.
    • tests/test_offered_languages.py:96-106: test_a_withdrawn_language_is_not_applied_to_the_page takes client and never requests a page; it asserts only site.offers() and UserPreferencesMiddleware._offered().
  • What happens: checked with the test client on an instance that offers only German and has German as its default (offered_languages=["de"], default_language="de"):
    1. A profile set to fr-fr (withdrawn) with Accept-Language: fr gets <html lang="fr-fr"> and Content-Language: fr-fr, the very language that was withdrawn. With Accept-Language: pt it gets pt-pt, a language the instance never offered. With no header it gets en-gb, not German.
    2. A profile set to Use the instance default gets the same three answers. It reaches German only when the browser asks for German.
    3. A visitor who is not signed in gets the sign-in page as lang="sw" for a Swahili browser and <html lang="ar" dir="rtl"> for an Arabic one. Both catalogues stand at 0 of 1873 in locale/status.json, so Postulo's own text is English in a page declared as Swahili or Arabic, the second laid out right to left.
  • Why it matters:
    • The wiki promises otherwise. Configuration says that somebody whose language is withdrawn "reads it in the instance's default language from their next page onwards" (Configuration.md:60-62 in postulo.wiki), and Accessibility says "No right-to-left language is offered yet" (Accessibility.md:182).
    • An administrator who withdraws a language, typically because its translation is incomplete, still has it served to everybody whose browser asks for it.
    • language_choices() keeps a language whose catalogue has not begun off the picker, so that nobody is offered their language with an English interface behind it (the rule from #70). The middleware applies no such filter, so the browser reaches what the picker hides.
    • A lang attribute that names the wrong language fails WCAG 2.2 success criterion 3.1.1 (Language of Page): a screen reader pronounces English text by Swahili or Arabic rules.
    • The test named for this behaviour cannot fail on any of it, which is how it went unnoticed.
  • Fix: in UserPreferencesMiddleware.__call__, when no offered profile language applies, do not leave LocaleMiddleware's answer in place unchecked. Keep it only if the instance offers it and its catalogue has begun (the two filters language_choices() applies; share them as one function instead of copying them); otherwise activate site.default_language(). Set request.LANGUAGE_CODE to the result either way, and leave the stored profile value untouched, as now. Then decide what the empty profile choice means and make the label, the docstring and the behaviour agree: either it follows the instance default regardless of the browser (the label as written, and what the wiki says of a withdrawn language), or it follows the browser within the offered list and is relabelled. A withdrawn profile language should get the instance default in both cases, as Configuration says.

Done when

  • test_a_withdrawn_language_is_not_applied_to_the_page in tests/test_offered_languages.py requests a page and asserts its lang attribute and Content-Language: a person whose profile language is withdrawn, with Accept-Language naming that language and then another unoffered one, gets the instance default both times. It fails today.
  • The same file covers a profile set to Use the instance default (the outcome chosen above, never an unoffered language) and a visitor who is not signed in whose browser asks for an unoffered language, and for one whose catalogue has not begun: each gets the instance default. All fail today.
  • The label at accounts/forms.py:201, the docstring at accounts/forms.py:153 and Configuration.md describe the behaviour the tests pin.

Related to #120 (closed), which let an administrator narrow the offered languages and promised that a withdrawn language gives way to the instance default; this issue adds that the page middleware never applies that default, that signed-out visitors and the empty profile choice bypass the offered list altogether, and that the test meant to guard it requests no page. Related also to #322, which is about the Defaults form, not the language a page is served in.

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the locale middleware against the *Configuration* page of the wiki and requesting pages with different `Accept-Language` headers through the test client. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `core/site.py:213-232` (`default_language()`, `offered_languages()` and `offers()`), `accounts/forms.py:202` (the *Use the instance default* label) and `accounts/forms.py:154` (the docstring of `language_choices()`); the middleware, the settings and the test have not moved, and `locale/status.json` still shows Swahili and Arabic at 0 of 1873. A profile's language is applied only when the instance offers it. In every other case (a profile language that has been withdrawn, a profile set to *Use the instance default*, a visitor who is not signed in) the page keeps whatever Django's `LocaleMiddleware` negotiated from the language cookie or the browser's `Accept-Language`, against all 69 entries of `LANGUAGES`, falling back to `en-gb`. Neither the offered list nor the instance's default language is consulted. A withdrawn language keeps being served to everybody whose browser asks for it, the instance default is never used, and a language whose catalogue nobody has begun is declared on pages whose text is English. - **Where:** - `core/middleware.py:135-143` (`UserPreferencesMiddleware.__call__`): `if language and not self._offered(language): language = ""`, then `if language: translation.activate(language)`, with no other branch. The choice made earlier by `django.middleware.locale.LocaleMiddleware` (`config/settings/base.py:95`) stands. - `config/settings/base.py:450-455`: `LANGUAGE_CODE = "en-gb"` and `LANGUAGES = languages.LANGUAGES`, the only list `LocaleMiddleware` knows. - `core/site.py:251-270`: `default_language()`, `offered_languages()` and `offers()` are read by documents, notifications and the picker, and by the middleware only to reject a profile language. - `accounts/forms.py:201`: the empty choice is labelled *Use the instance default*, while the docstring of `language_choices()` (`accounts/forms.py:153`) calls it "an option to follow the browser". The code does the second. - `tests/test_offered_languages.py:96-106`: `test_a_withdrawn_language_is_not_applied_to_the_page` takes `client` and never requests a page; it asserts only `site.offers()` and `UserPreferencesMiddleware._offered()`. - **What happens:** checked with the test client on an instance that offers only German and has German as its default (`offered_languages=["de"]`, `default_language="de"`): 1. A profile set to `fr-fr` (withdrawn) with `Accept-Language: fr` gets `<html lang="fr-fr">` and `Content-Language: fr-fr`, the very language that was withdrawn. With `Accept-Language: pt` it gets `pt-pt`, a language the instance never offered. With no header it gets `en-gb`, not German. 2. A profile set to *Use the instance default* gets the same three answers. It reaches German only when the browser asks for German. 3. A visitor who is not signed in gets the sign-in page as `lang="sw"` for a Swahili browser and `<html lang="ar" dir="rtl">` for an Arabic one. Both catalogues stand at 0 of 1873 in `locale/status.json`, so Postulo's own text is English in a page declared as Swahili or Arabic, the second laid out right to left. - **Why it matters:** - The wiki promises otherwise. *Configuration* says that somebody whose language is withdrawn "reads it in the instance's default language from their next page onwards" (`Configuration.md:60-62` in postulo.wiki), and *Accessibility* says "No right-to-left language is offered yet" (`Accessibility.md:182`). - An administrator who withdraws a language, typically because its translation is incomplete, still has it served to everybody whose browser asks for it. - `language_choices()` keeps a language whose catalogue has not begun off the picker, so that nobody is offered their language with an English interface behind it (the rule from #70). The middleware applies no such filter, so the browser reaches what the picker hides. - A `lang` attribute that names the wrong language fails WCAG 2.2 success criterion 3.1.1 (Language of Page): a screen reader pronounces English text by Swahili or Arabic rules. - The test named for this behaviour cannot fail on any of it, which is how it went unnoticed. - **Fix:** in `UserPreferencesMiddleware.__call__`, when no offered profile language applies, do not leave `LocaleMiddleware`'s answer in place unchecked. Keep it only if the instance offers it and its catalogue has begun (the two filters `language_choices()` applies; share them as one function instead of copying them); otherwise activate `site.default_language()`. Set `request.LANGUAGE_CODE` to the result either way, and leave the stored profile value untouched, as now. Then decide what the empty profile choice means and make the label, the docstring and the behaviour agree: either it follows the instance default regardless of the browser (the label as written, and what the wiki says of a withdrawn language), or it follows the browser within the offered list and is relabelled. A withdrawn profile language should get the instance default in both cases, as *Configuration* says. ## Done when - `test_a_withdrawn_language_is_not_applied_to_the_page` in `tests/test_offered_languages.py` requests a page and asserts its `lang` attribute and `Content-Language`: a person whose profile language is withdrawn, with `Accept-Language` naming that language and then another unoffered one, gets the instance default both times. It fails today. - The same file covers a profile set to *Use the instance default* (the outcome chosen above, never an unoffered language) and a visitor who is not signed in whose browser asks for an unoffered language, and for one whose catalogue has not begun: each gets the instance default. All fail today. - The label at `accounts/forms.py:201`, the docstring at `accounts/forms.py:153` and `Configuration.md` describe the behaviour the tests pin. Related to #120 (closed), which let an administrator narrow the offered languages and promised that a withdrawn language gives way to the instance default; this issue adds that the page middleware never applies that default, that signed-out visitors and the empty profile choice bypass the offered list altogether, and that the test meant to guard it requests no page. Related also to #322, which is about the *Defaults* form, not the language a page is served in.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:24 +00:00
Author
Owner

The maintainer's list of 2026-10-02 reports this from the user's side: the chosen language is not respected “no matter the device”, because the page follows the browser's requested language, which differs between desktop and mobile.

That fits this issue; the lines have moved. Since #393 the rule is src/postulo/core/preferences.py:50-56, called from UserPreferencesMiddleware (core/middleware.py:175-206). A stored language that is still offered is activated. A blank one, or a visit with nobody signed in, keeps what LocaleMiddleware took from Accept-Language (core/middleware.py:151-172). The instance default is never consulted for the page.

#698 relies on this fix: its “Default” label is only true once a blank language follows the instance default.

From the list of improvements of 2026-10-02.

The maintainer's list of 2026-10-02 reports this from the user's side: the chosen language is not respected “no matter the device”, because the page follows the browser's requested language, which differs between desktop and mobile. That fits this issue; the lines have moved. Since #393 the rule is `src/postulo/core/preferences.py:50-56`, called from `UserPreferencesMiddleware` (`core/middleware.py:175-206`). A stored language that is still offered is activated. A blank one, or a visit with nobody signed in, keeps what `LocaleMiddleware` took from `Accept-Language` (`core/middleware.py:151-172`). The instance default is never consulted for the page. #698 relies on this fix: its “Default” label is only true once a blank language follows the instance default. From the list of improvements of 2026-10-02.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#398
No description provided.