[req,web] Make it possible to handle page language from URL prefix
authorLaura Médioni <laura.medioni@logilab.fr>, Denis Laxalde <denis.laxalde@logilab.fr>
Thu, 03 Nov 2016 15:00:01 +0100
changeset 11794 d8830e2bd2e0
parent 11793 b455460630a0
child 11795 031c99666221
[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
cubicweb/req.py
cubicweb/test/unittest_req.py
cubicweb/web/request.py
cubicweb/web/test/unittest_application.py
cubicweb/web/test/unittest_request.py
cubicweb/web/views/urlpublishing.py
cubicweb/web/webconfig.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"""
--- 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,
           }),