# HG changeset patch # User Laura Médioni , Denis Laxalde # Date 1478181601 -3600 # Node ID d8830e2bd2e0821728b575890ab1649a17152a3a # Parent b455460630a06212aa87e74fa64909e7e81c167d [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 diff -r b455460630a0 -r d8830e2bd2e0 cubicweb/req.py --- 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""" diff -r b455460630a0 -r d8830e2bd2e0 cubicweb/test/unittest_req.py --- 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() diff -r b455460630a0 -r d8830e2bd2e0 cubicweb/web/request.py --- 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): diff -r b455460630a0 -r d8830e2bd2e0 cubicweb/web/test/unittest_application.py --- 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 diff -r b455460630a0 -r d8830e2bd2e0 cubicweb/web/test/unittest_request.py --- 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__': diff -r b455460630a0 -r d8830e2bd2e0 cubicweb/web/views/urlpublishing.py --- 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 diff -r b455460630a0 -r d8830e2bd2e0 cubicweb/web/webconfig.py --- 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, }),