Skip to content
This repository has been archived by the owner on Mar 15, 2024. It is now read-only.

Commit

Permalink
Merge pull request #187 from mozilla/feature/url-case
Browse files Browse the repository at this point in the history
Add custom middleware and URL resolvers for ISO 3166 language prefixes.
  • Loading branch information
solarissmoke authored Aug 21, 2019
2 parents 3e66447 + f582d53 commit eb77d30
Show file tree
Hide file tree
Showing 6 changed files with 218 additions and 17 deletions.
63 changes: 63 additions & 0 deletions donate/core/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from django.conf import settings
from django.conf.urls.i18n import is_language_prefix_patterns_used
from django.middleware.locale import LocaleMiddleware as BaseLocaleMiddleware
from django.urls import get_script_prefix, is_valid_path
from django.utils import translation
from django.utils.cache import patch_vary_headers

from .utils import get_language_from_request, language_code_to_iso_3166


class LocaleMiddleware(BaseLocaleMiddleware):
"""
A replacement for django.middleware.locale.LocaleMiddleware that
redirects to an ISO 3166 format language prefix (en-US) instead of
the form used by Django (en-us)
"""

def process_request(self, request):
urlconf = getattr(request, 'urlconf', settings.ROOT_URLCONF)
i18n_patterns_used, prefixed_default_language = is_language_prefix_patterns_used(urlconf)
# We're using our version of get_language_from_request here.
language = get_language_from_request(request, check_path=i18n_patterns_used)
language_from_path = translation.get_language_from_path(request.path_info)
if not language_from_path and i18n_patterns_used and not prefixed_default_language:
language = settings.LANGUAGE_CODE
translation.activate(language)
request.LANGUAGE_CODE = translation.get_language()

def process_response(self, request, response):
language = translation.get_language()
# Rewrite language to the format we want it in.
language = language_code_to_iso_3166(language)
language_from_path = translation.get_language_from_path(request.path_info)
urlconf = getattr(request, 'urlconf', settings.ROOT_URLCONF)
i18n_patterns_used, prefixed_default_language = is_language_prefix_patterns_used(urlconf)
if (response.status_code == 404 and not language_from_path and
i18n_patterns_used and prefixed_default_language):
# Maybe the language code is missing in the URL? Try adding the
# language prefix and redirecting to that URL.
language_path = '/%s%s' % (language, request.path_info)
path_valid = is_valid_path(language_path, urlconf)
path_needs_slash = (
not path_valid and (
settings.APPEND_SLASH and not language_path.endswith('/') and
is_valid_path('%s/' % language_path, urlconf)
)
)

if path_valid or path_needs_slash:
script_prefix = get_script_prefix()
# Insert language after the script prefix and before the
# rest of the URL
language_url = request.get_full_path(force_append_slash=path_needs_slash).replace(
script_prefix,
'%s%s/' % (script_prefix, language),
1
)
return self.response_redirect_class(language_url)

if not (i18n_patterns_used and language_from_path):
patch_vary_headers(response, ('Accept-Language',))
response.setdefault('Content-Language', language)
return response
21 changes: 21 additions & 0 deletions donate/core/tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.utils import translation

from ..middleware import LocaleMiddleware


class LocaleMiddlewareTestCase(TestCase):

def test_process_request_sets_iso_3166_language_code(self):
request = RequestFactory().get('/en-GB/')
LocaleMiddleware().process_request(request)
self.assertEqual(request.LANGUAGE_CODE, 'en-gb')

def test_process_response_redirects_to_iso_3166_language_code(self):
request = RequestFactory().get('/')
response = HttpResponse(status=404)
translation.activate('en-gb')
new_response = LocaleMiddleware().process_response(request, response)
self.assertEqual(new_response.status_code, 302)
self.assertEqual(new_response['Location'], '/en-GB/')
32 changes: 32 additions & 0 deletions donate/core/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from django.test import RequestFactory, TestCase
from django.utils import translation

from ..utils import ISO3166LocalePrefixPattern, get_language_from_request, language_code_to_iso_3166


class UtilsTestCase(TestCase):

def test_get_language_code_to_iso_3166(self):
self.assertEqual(language_code_to_iso_3166('en-gb'), 'en-GB')
self.assertEqual(language_code_to_iso_3166('en-us'), 'en-US')
self.assertEqual(language_code_to_iso_3166('fr'), 'fr')

def test_ISO3166LocalePrefixPattern(self):
translation.deactivate()
pattern = ISO3166LocalePrefixPattern(prefix_default_language=True)
# Pattern should not match lowercase URLs
self.assertIsNone(pattern.match('en-us/'))
# It should match this URL
self.assertTrue(pattern.match('en-US/'))

def test_get_language_from_request_returns_iso_3166_language(self):
request = RequestFactory().get('/')
request.META['HTTP_ACCEPT_LANGUAGE'] = 'en-GB,en;q=0.5'
language = get_language_from_request(request)
self.assertEqual(language, 'en-GB')

def test_get_language_from_request_fallback_language(self):
request = RequestFactory().get('/')
request.META['HTTP_ACCEPT_LANGUAGE'] = 'en-FO,en;q=0.5'
language = get_language_from_request(request)
self.assertEqual(language, 'en-US')
81 changes: 81 additions & 0 deletions donate/core/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from django.conf import settings
from django.urls import LocalePrefixPattern, URLResolver
from django.utils.translation.trans_real import (
check_for_language, get_languages, get_language_from_path,
get_supported_language_variant, parse_accept_lang_header, language_code_re
)


class ISO3166LocalePrefixPattern(LocalePrefixPattern):
"""LocalePrefixPattern subclass that enforces URL prefixes in the form en-US"""

def match(self, path):
language_prefix = language_code_to_iso_3166(self.language_prefix)
if path.startswith(language_prefix):
return path[len(language_prefix):], (), {}
return None


def i18n_patterns(*urls, prefix_default_language=True):
"""
Replacement for django.conf.urls.i18_patterns that uses ISO3166LocalePrefixPattern
instead of django.urls.resolvers.LocalePrefixPattern.
"""
if not settings.USE_I18N:
return list(urls)
return [
URLResolver(
ISO3166LocalePrefixPattern(prefix_default_language=prefix_default_language),
list(urls),
)
]


def language_code_to_iso_3166(language):
"""Turn a language name (en-us) into an ISO 3166 format (en-US)."""
language, _, country = language.lower().partition('-')
if country:
return language + '-' + country.upper()
return language


def get_language_from_request(request, check_path=False):
"""
Replacement for django.utils.translation.get_language_from_request.
The portion of code that is modified is identified below with a comment.
"""
if check_path:
lang_code = get_language_from_path(request.path_info)
if lang_code is not None:
return lang_code

lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
if lang_code is not None and lang_code in get_languages() and check_for_language(lang_code):
return lang_code

try:
return get_supported_language_variant(lang_code)
except LookupError:
pass

accept = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
for accept_lang, unused in parse_accept_lang_header(accept):
if accept_lang == '*':
break

# Convert lowercase region to uppercase before attempting to find a variant.
# This is the only portion of code that is modified from the core function.
accept_lang = language_code_to_iso_3166(accept_lang)

if not language_code_re.search(accept_lang):
continue

try:
return get_supported_language_variant(accept_lang)
except LookupError:
continue

try:
return get_supported_language_variant(settings.LANGUAGE_CODE)
except LookupError:
return settings.LANGUAGE_CODE
36 changes: 20 additions & 16 deletions donate/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'donate.core.middleware.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
Expand Down Expand Up @@ -190,12 +190,16 @@
},
]

LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = 'en-US'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True

LOCALE_PATHS = [
app('locale'),
]

LANGUAGES = [
('ach', 'Acholi'),
('ar', 'Arabic'),
Expand All @@ -209,16 +213,16 @@
('de', 'German'),
('dsb', 'Sorbian, Lower'),
('el', 'Greek'),
('en-us', 'English (US)'),
('en-ca', 'English (Canada)'),
('en-gb', 'English (Great Britain)'),
('en-US', 'English (US)'),
('en-CA', 'English (Canada)'),
('en-GB', 'English (Great Britain)'),
('es', 'Spanish'),
('et', 'Estonian'),
('fr', 'French'),
('fy-nl', 'Frisian'),
('gu-in', 'Gujarati'),
('fy-NL', 'Frisian'),
('gu-IN', 'Gujarati'),
('he', 'Hebrew'),
('hi-in', 'Hindi'),
('hi-IN', 'Hindi'),
('hr', 'Croatian'),
('hsb', 'Sorbian, Upper'),
('hu', 'Hungarian'),
Expand All @@ -234,26 +238,26 @@
('ms', 'Malay'),
('ml', 'Malayalam'),
('mr', 'Marathi'),
('nb-no', 'Norwegian Bokmål'),
('nb-NO', 'Norwegian Bokmål'),
('nl', 'Dutch'),
('nn-no', 'Norwegian Nynorsk'),
('nn-NO', 'Norwegian Nynorsk'),
('pl', 'Polish'),
('pt-br', 'Portuguese (Brazil)'),
('pt-pt', 'Portuguese (Portugal)'),
('pa-in', 'Punjabi'),
('pt-BR', 'Portuguese (Brazil)'),
('pt-PT', 'Portuguese (Portugal)'),
('pa-IN', 'Punjabi'),
('ro', 'Romanian'),
('ru', 'Russian'),
('sk', 'Slovak'),
('sl', 'Slovenian'),
('sq', 'Albanian'),
('sv-se', 'Swedish'),
('sv-SE', 'Swedish'),
('ta', 'Tamil'),
('te', 'Telugu'),
('th', 'Thai'),
('tr', 'Turkish'),
('uz', 'Uzbek'),
('zh-cn', 'Chinese (China)'),
('zh-tw', 'Chinese (Taiwan)'),
('zh-CN', 'Chinese (China)'),
('zh-TW', 'Chinese (Taiwan)'),
]

STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
Expand Down
2 changes: 1 addition & 1 deletion donate/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from django.apps import apps
from django.conf import settings
from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import include, path
from django.views.decorators.cache import cache_page
Expand All @@ -12,6 +11,7 @@
from wagtail.documents import urls as wagtaildocs_urls

from donate.payments import urls as payments_urls
from donate.core.utils import i18n_patterns

# Patterns not subject to i18n
urlpatterns = [
Expand Down

0 comments on commit eb77d30

Please sign in to comment.