[req,web] Make it possible to handle page language from URL prefix
Adding a short language prefix to URL (like "/en" or "/fr") changes the
language the pages are displayed in. This prefix is kept during navigation.
This way it is not necessary to do language negotiation, nor to use user
preferences to determine which language to apply.
This behavior is controlled through a new configuration option
"language-mode", which replaces "language-negociation" option and which values
may be "http-negotiation", "url-prefix" or "" (to disable language setting and
force using "ui.language" property). Migration from previous option is not
handled because I could not manage to have it working (users will get prompted
with the configuration file diff anyways).
Add some tests checking various scenarios.
Closes #15743487
--- a/cubicweb/req.py Wed Nov 02 15:59:39 2016 +0100
+++ b/cubicweb/req.py Thu Nov 03 15:00:01 2016 +0100
@@ -76,6 +76,7 @@
self.user = None
self.local_perm_cache = {}
self._ = text_type
+ self.lang = None
def _set_user(self, orig_user):
"""set the user for this req_session_base
@@ -293,9 +294,13 @@
path = kwargs.pop('_restpath')
else:
path = method
+ language = ''
+ if self.lang and self.vreg.config.get('language-mode') == 'url-prefix':
+ language = '%s/' % self.lang
if not kwargs:
- return u'%s%s' % (base_url, path)
- return u'%s%s?%s' % (base_url, path, self.build_url_params(**kwargs))
+ return u'%s%s%s' % (base_url, language, path)
+ return u'%s%s%s?%s' % (base_url, language, path,
+ self.build_url_params(**kwargs))
def build_url_params(self, **kwargs):
"""return encoded params to incorporate them in a URL"""
--- a/cubicweb/test/unittest_req.py Wed Nov 02 15:59:39 2016 +0100
+++ b/cubicweb/test/unittest_req.py Thu Nov 03 15:00:01 2016 +0100
@@ -19,6 +19,7 @@
from logilab.common.testlib import TestCase, unittest_main
from cubicweb import ObjectNotFound
from cubicweb.req import RequestSessionBase, FindEntityError
+from cubicweb.devtools import ApptestConfiguration
from cubicweb.devtools.testlib import CubicWebTC
from cubicweb import Unauthorized
@@ -44,6 +45,31 @@
self.assertRaises(AssertionError, req.build_url, 'one', 'two not allowed')
self.assertRaises(AssertionError, req.build_url, 'view', test=None)
+ def test_build_url_language_from_url(self):
+ # need req.vreg.config to exist because lang is read in it at set_language() call
+ vreg = MockVReg()
+ vreg.config.global_set_option('language-mode', 'url-prefix')
+ req = RequestSessionBase(vreg)
+ req.base_url = lambda secure=None: 'http://testing.fr/cubicweb/'
+ self.assertIsNone(req.lang) # language unset yet.
+ self.assertEqual(req.build_url(), 'http://testing.fr/cubicweb/view')
+ self.assertEqual(req.build_url('foo'), 'http://testing.fr/cubicweb/foo')
+ req.set_language('fr')
+ self.assertEqual(req.lang, 'fr')
+ self.assertEqual(req.build_url(), 'http://testing.fr/cubicweb/fr/view')
+ self.assertEqual(req.build_url('foo'), 'http://testing.fr/cubicweb/fr/foo')
+ req.set_language('en')
+ self.assertEqual(req.lang, 'en')
+ self.assertEqual(req.build_url(), 'http://testing.fr/cubicweb/en/view')
+ self.assertEqual(req.build_url('foo'), 'http://testing.fr/cubicweb/en/foo')
+ # no language prefix in URL
+ vreg.config.global_set_option('language-mode', '')
+ self.assertEqual(req.build_url(), 'http://testing.fr/cubicweb/view')
+ self.assertEqual(req.build_url('foo'), 'http://testing.fr/cubicweb/foo')
+ req.set_language('fr')
+ self.assertEqual(req.build_url(), 'http://testing.fr/cubicweb/view')
+ self.assertEqual(req.build_url('foo'), 'http://testing.fr/cubicweb/foo')
+
def test_ensure_no_rql(self):
req = RequestSessionBase(None)
self.assertEqual(req.ensure_ro_rql('Any X WHERE X is CWUser'), None)
@@ -52,6 +78,13 @@
self.assertRaises(Unauthorized, req.ensure_ro_rql, ' SET X login "toto" WHERE X is CWUser ')
+class MockVReg(object):
+ """Fake VReg with just a basic config in it.
+ """
+ def __init__(self):
+ self.config = ApptestConfiguration('data', __file__)
+
+
class RequestCWTC(CubicWebTC):
def test_base_url(self):
@@ -149,5 +182,6 @@
with self.assertRaises(NotImplementedError):
req.find('CWUser', in_group=[1, 2])
+
if __name__ == '__main__':
unittest_main()
--- a/cubicweb/web/request.py Wed Nov 02 15:59:39 2016 +0100
+++ b/cubicweb/web/request.py Thu Nov 03 15:00:01 2016 +0100
@@ -915,25 +915,25 @@
def html_content_type(self):
return 'text/html'
+ def negotiated_language(self):
+ self.headers_out.addHeader('Vary', 'Accept-Language')
+ for lang in self.header_accept_language():
+ if lang in self.translations:
+ return lang
+ return None
+
def set_user_language(self, user):
vreg = self.vreg
if user is not None:
try:
- # 1. user-specified language
lang = vreg.typed_value('ui.language', user.properties['ui.language'])
self.set_language(lang)
return
except KeyError:
pass
- if vreg.config.get('language-negociation', False):
- # 2. http accept-language
- self.headers_out.addHeader('Vary', 'Accept-Language')
- for lang in self.header_accept_language():
- if lang in self.translations:
- self.set_language(lang)
- return
- # 3. site's default language
- self.set_default_language(vreg)
+ # site's default language
+ if self.lang is None:
+ self.set_default_language(vreg)
def _cnx_func(name):
--- a/cubicweb/web/test/unittest_application.py Wed Nov 02 15:59:39 2016 +0100
+++ b/cubicweb/web/test/unittest_application.py Thu Nov 03 15:00:01 2016 +0100
@@ -216,6 +216,87 @@
{'login-subject': 'required field'})
self.assertEqual(forminfo['values'], req.form)
+ def test_handle_request_with_lang_fromurl(self):
+ """No language negociation, get language from URL."""
+ self.config.global_set_option('language-mode', 'url-prefix')
+ req, origsession = self.init_authentication('http')
+ self.assertEqual(req.url(), 'http://testing.fr/cubicweb/login')
+ self.assertEqual(req.lang, 'en')
+ self.app.handle_request(req)
+ newreq = self.requestcls(req.vreg, url='fr/toto')
+ self.assertEqual(newreq.lang, 'en')
+ self.assertEqual(newreq.url(), 'http://testing.fr/cubicweb/fr/toto')
+ self.app.handle_request(newreq)
+ self.assertEqual(newreq.lang, 'fr')
+ self.assertEqual(newreq.url(), 'http://testing.fr/cubicweb/fr/toto')
+ # unknown language
+ newreq = self.requestcls(req.vreg, url='unknown-lang/cwuser')
+ result = self.app.handle_request(newreq)
+ self.assertEqual(newreq.lang, 'en')
+ self.assertEqual(newreq.url(), 'http://testing.fr/cubicweb/unknown-lang/cwuser')
+ self.assertIn('this resource does not exist',
+ result.decode('ascii', errors='ignore'))
+ # no prefix
+ newreq = self.requestcls(req.vreg, url='cwuser')
+ result = self.app.handle_request(newreq)
+ self.assertEqual(newreq.lang, 'en')
+ self.assertEqual(newreq.url(), 'http://testing.fr/cubicweb/cwuser')
+ self.assertNotIn('this resource does not exist',
+ result.decode('ascii', errors='ignore'))
+
+ def test_handle_request_with_lang_negotiated(self):
+ """Language negociated, normal case."""
+ self.config.global_set_option('language-mode', 'http-negotiation')
+ orig_translations = self.config.translations.copy()
+ self.config.translations = {'fr': (text_type, text_type),
+ 'en': (text_type, text_type)}
+ try:
+ headers = {'Accept-Language': 'fr'}
+ with self.admin_access.web_request(headers=headers) as req:
+ self.app.handle_request(req)
+ self.assertEqual(req.lang, 'fr')
+ finally:
+ self.config.translations = orig_translations
+
+ def test_handle_request_with_lang_negotiated_prefix_in_url(self):
+ """Language negociated, unexpected language prefix in URL."""
+ self.config.global_set_option('language-mode', 'http-negotiation')
+ with self.admin_access.web_request(url='fr/toto') as req:
+ result = self.app.handle_request(req)
+ self.assertIn('this resource does not exist', # NotFound.
+ result.decode('ascii', errors='ignore'))
+
+ def test_handle_request_no_lang_negotiation_fixed_language(self):
+ """No language negociation, "ui.language" fixed."""
+ self.config.global_set_option('language-mode', '')
+ vreg = self.app.vreg
+ self.assertEqual(vreg.property_value('ui.language'), 'en')
+ props = []
+ try:
+ with self.admin_access.cnx() as cnx:
+ props.append(cnx.create_entity('CWProperty', value=u'de',
+ pkey=u'ui.language').eid)
+ cnx.commit()
+ self.assertEqual(vreg.property_value('ui.language'), 'de')
+ headers = {'Accept-Language': 'fr'} # should not have any effect.
+ with self.admin_access.web_request(headers=headers) as req:
+ self.app.handle_request(req)
+ # user has no "ui.language" property, getting site's default.
+ self.assertEqual(req.lang, 'de')
+ with self.admin_access.cnx() as cnx:
+ props.append(cnx.create_entity('CWProperty', value=u'es',
+ pkey=u'ui.language',
+ for_user=cnx.user).eid)
+ cnx.commit()
+ with self.admin_access.web_request(headers=headers) as req:
+ self.app.handle_request(req)
+ self.assertEqual(req.lang, 'es')
+ finally:
+ with self.admin_access.cnx() as cnx:
+ for peid in props:
+ cnx.entity_from_eid(peid).cw_delete()
+ cnx.commit()
+
def test_validation_error_dont_loose_subentity_data_repo(self):
"""test creation of two linked entities
--- a/cubicweb/web/test/unittest_request.py Wed Nov 02 15:59:39 2016 +0100
+++ b/cubicweb/web/test/unittest_request.py Thu Nov 03 15:00:01 2016 +0100
@@ -83,6 +83,15 @@
self.assertEqual('http://babar.com/', req.base_url(False))
self.assertEqual('https://toto.com/', req.base_url(True))
+ def test_negotiated_language(self):
+ vreg = type('DummyVreg', (object,), {})()
+ vreg.config = FakeConfig()
+ vreg.config.translations = {'fr': (None, None), 'en': (None, None)}
+ headers = {
+ 'Accept-Language': 'fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3',
+ }
+ req = CubicWebRequestBase(vreg, https=False, headers=headers)
+ self.assertEqual(req.negotiated_language(), 'fr')
if __name__ == '__main__':
--- a/cubicweb/web/views/urlpublishing.py Wed Nov 02 15:59:39 2016 +0100
+++ b/cubicweb/web/views/urlpublishing.py Thu Nov 03 15:00:01 2016 +0100
@@ -115,7 +115,21 @@
:raise NotFound: if no handler is able to decode the given path
"""
parts = [part for part in path.split('/')
- if part != ''] or (self.default_method,)
+ if part != ''] or [self.default_method]
+ language_mode = self.vreg.config.get('language-mode')
+ if (language_mode == 'url-prefix'
+ and parts and parts[0] in self.vreg.config.available_languages()):
+ # language from URL
+ req.set_language(parts.pop(0))
+ path = '/'.join(parts)
+ # if parts only contains lang, use 'view' default path
+ if not parts:
+ parts = (self.default_method,)
+ elif language_mode in ('http-negotiation', 'url-prefix'):
+ # negotiated language
+ lang = req.negotiated_language()
+ if lang:
+ req.set_language(lang)
if req.form.get('rql'):
if parts[0] in self.vreg['controllers']:
return parts[0], None
--- a/cubicweb/web/webconfig.py Wed Nov 02 15:59:39 2016 +0100
+++ b/cubicweb/web/webconfig.py Thu Nov 03 15:00:01 2016 +0100
@@ -178,11 +178,13 @@
'group': 'web', 'level': 2,
}),
- ('language-negociation',
- {'type' : 'yn',
- 'default': True,
- 'help': 'use Accept-Language http header to try to set user '\
- 'interface\'s language according to browser defined preferences',
+ ('language-mode',
+ {'type' : 'choice',
+ 'choices': ('http-negotiation', 'url-prefix', ''),
+ 'default': 'http-negotiation',
+ 'help': ('source for interface\'s language detection. '
+ 'If set to "http-negotiation" the Accept-Language HTTP header will be used,'
+ ' if set to "url-prefix", the URL will be inspected for a short language prefix.'),
'group': 'web', 'level': 2,
}),