Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/core/janeway_global_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
93 changes: 61 additions & 32 deletions src/journal/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
73 changes: 73 additions & 0 deletions src/journal/tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -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"]))
37 changes: 37 additions & 0 deletions src/utils/language.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions src/utils/testing/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading