diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py index 526f22a626..291fb58ecf 100755 --- a/src/core/janeway_global_settings.py +++ b/src/core/janeway_global_settings.py @@ -120,11 +120,10 @@ "core.middleware.MaintenanceModeMiddleware", "cron.middleware.CronMiddleware", "core.middleware.CounterCookieMiddleware", - "django.middleware.locale.LocaleMiddleware", + "journal.middleware.JournalLocaleMiddleware", "core.middleware.PressMiddleware", "core.middleware.GlobalRequestMiddleware", "django.middleware.gzip.GZipMiddleware", - "journal.middleware.LanguageMiddleware", "hijack.middleware.HijackUserMiddleware", "simple_history.middleware.HistoryRequestMiddleware", ) diff --git a/src/journal/middleware.py b/src/journal/middleware.py index 2b284ffd17..fb22a961a1 100644 --- a/src/journal/middleware.py +++ b/src/journal/middleware.py @@ -3,47 +3,76 @@ __license__ = "AGPL v3" __maintainer__ = "Birkbeck Centre for Technology and Publishing" -from django.utils import translation from django.conf import settings +from django.middleware.locale import LocaleMiddleware +from django.utils import translation +from utils.language import find_language_or_its_variant from utils.logger import get_logger -from utils.middleware import BaseMiddleware logger = get_logger(__name__) -class LanguageMiddleware(BaseMiddleware): - @staticmethod - def process_request(request): - """ - Checks that the currently set language is okay for the current journal. - """ - if request.journal and settings.USE_I18N: - current_language = translation.get_language() - available_languages = request.journal.get_setting( +class JournalLocaleMiddleware(LocaleMiddleware): + """ + A LocaleMiddleware that constrains the active language to those a journal + publishes in. + + Django's LocaleMiddleware selects a language from the session, the language + cookie or the Accept-Language header. On a journal site we additionally + require that language to be one the journal offers; otherwise the journal's + ``default_journal_language`` is used. This stops, for instance, a + Spanish-only journal being rendered with an English navigation bar simply + because the visitor's browser was last used on an English site (see #4313). + + Response handling (Content-Language and Vary headers) is inherited from + Django's LocaleMiddleware unchanged. + """ + + def process_request(self, request): + journal = getattr(request, "journal", None) + + if not journal or not settings.USE_I18N: + return super().process_request(request) + + available_languages = list( + journal.get_setting( group_name="general", setting_name="journal_languages", ) - default_language = request.journal.get_setting( - group_name="general", setting_name="default_journal_language" + or [] + ) + default_language = ( + journal.get_setting( + group_name="general", + setting_name="default_journal_language", + ) + or settings.LANGUAGE_CODE + ) + + # The default language must always be selectable. + if default_language not in available_languages: + available_languages.append(default_language) + + requested_language = translation.get_language_from_request( + request, + check_path=False, + ) + language = find_language_or_its_variant( + requested_language, + available_languages, + ) + if not language: + logger.debug( + "Requested language %s is not offered by the journal; " + "falling back to the default: %s", + requested_language, + default_language, ) + language = default_language - if not default_language: - default_language = settings.LANGUAGE_CODE - - if current_language not in available_languages: - translation.activate(default_language) - logger.debug( - "Current language not in the available languages." - " Activating default: {}".format(default_language) - ) - if not available_languages: - # If we have no languages use the defaults from settings. - _available_languages = [lang[0] for lang in settings.LANGUAGES] - else: - # The default language must always be in available_languages. - available_languages.append(settings.LANGUAGE_CODE) - - request.available_languages = set(available_languages) - request.default_language = default_language - request.current_language = translation.get_language() + translation.activate(language) + request.LANGUAGE_CODE = translation.get_language() + request.available_languages = set(available_languages) + request.default_language = default_language + request.current_language = request.LANGUAGE_CODE diff --git a/src/journal/tests/test_middleware.py b/src/journal/tests/test_middleware.py new file mode 100644 index 0000000000..a85c7d0cbb --- /dev/null +++ b/src/journal/tests/test_middleware.py @@ -0,0 +1,73 @@ +__copyright__ = "Copyright 2025 Birkbeck, University of London" +__author__ = "Birkbeck Centre for Technology and Publishing" +__license__ = "AGPL v3" +__maintainer__ = "Birkbeck Centre for Technology and Publishing" + +from django.http import HttpResponse +from django.test import TestCase +from django.test.client import RequestFactory +from django.utils import translation + +from journal.middleware import JournalLocaleMiddleware +from utils.language import find_language_or_its_variant +from utils.testing import helpers + + +class JournalLocaleMiddlewareTests(TestCase): + """Language constraint on journal sites (#4313).""" + + @classmethod + def setUpTestData(cls): + cls.journal_one, cls.journal_two = helpers.create_journals() + + def setUp(self): + self.factory = RequestFactory() + self.middleware = JournalLocaleMiddleware( + get_response=lambda request: HttpResponse(), + ) + self.addCleanup(translation.deactivate) + + def activate_language(self, journal, accept_language): + request = self.factory.get("/", HTTP_ACCEPT_LANGUAGE=accept_language) + request.journal = journal + self.middleware.process_request(request) + return request + + def test_browser_language_not_offered_falls_back_to_default(self): + helpers.set_journal_languages( + self.journal_one, + available=["es"], + default="es", + ) + request = self.activate_language(self.journal_one, "en") + self.assertEqual(request.LANGUAGE_CODE, "es") + + def test_browser_language_offered_is_honoured(self): + helpers.set_journal_languages( + self.journal_one, + available=["en", "es"], + default="en", + ) + request = self.activate_language(self.journal_one, "es") + self.assertEqual(request.LANGUAGE_CODE, "es") + + def test_unconfigured_journal_constrains_to_default(self): + # journal_two is left with the shipped defaults (journal_languages=[] + # and default_journal_language="en"), the configuration of nearly every + # existing journal. The browser's Accept-Language must be ignored. + request = self.activate_language(self.journal_two, "fr") + self.assertEqual(request.LANGUAGE_CODE, "en") + self.assertEqual(request.available_languages, {"en"}) + + def test_no_journal_falls_through_to_django_default(self): + request = self.factory.get("/", HTTP_ACCEPT_LANGUAGE="fr") + request.journal = None + self.middleware.process_request(request) + self.assertEqual(request.LANGUAGE_CODE, "fr") + + def test_resolves_language_variant(self): + self.assertEqual( + find_language_or_its_variant("en-GB", ["es", "en-US"]), + "en-US", + ) + self.assertIsNone(find_language_or_its_variant("de", ["en", "es"])) diff --git a/src/utils/language.py b/src/utils/language.py new file mode 100644 index 0000000000..7b055e78d3 --- /dev/null +++ b/src/utils/language.py @@ -0,0 +1,37 @@ +__copyright__ = "Copyright 2025 Birkbeck, University of London" +__author__ = "Birkbeck Centre for Technology and Publishing" +__license__ = "AGPL v3" +__maintainer__ = "Birkbeck Centre for Technology and Publishing" + + +def find_language_or_its_variant(requested_language, available_languages): + """ + Resolves a requested language against the languages a journal offers. + + Returns, in priority order: + - the requested language, if it is available, or + - the base language of the requested language (e.g. en for en-GB), or + - a variant of the requested language (e.g. en-US for en-GB), or + - None if no suitable language is available. + + When several variants match, the alphabetically-first is returned; the + order of available_languages is not significant. + """ + if not requested_language or not available_languages: + return None + + if requested_language in available_languages: + return requested_language + + base_language = requested_language.split("-")[0] + + if base_language in available_languages: + return base_language + + base_matches = [ + lang for lang in available_languages if lang.startswith(base_language + "-") + ] + if base_matches: + return sorted(base_matches)[0] + + return None diff --git a/src/utils/testing/helpers.py b/src/utils/testing/helpers.py index 8479dcea68..d7b140649a 100755 --- a/src/utils/testing/helpers.py +++ b/src/utils/testing/helpers.py @@ -119,6 +119,29 @@ def create_journals(): return journal_one, journal_two +def set_journal_languages(journal, available=None, default="en"): + """ + Configures the languages a journal publishes in, as used by + JournalLocaleMiddleware. + + :param journal: the Journal to configure + :param available: a list of language codes for the journal_languages setting + :param default: the default_journal_language code + """ + setting_handler.save_setting( + "general", + "journal_languages", + journal, + available or [], + ) + setting_handler.save_setting( + "general", + "default_journal_language", + journal, + default, + ) + + def create_press(): press, created = press_models.Press.objects.get_or_create( name="Press",