Repository: django-cms/django-cms

Issue:
DefaultLanguageChanger crash when instantiating type arguments
After upgrading to the latest version of Django CMS, we're now getting errors when viewing a page with type arguments in the kwargs, e.g.:

```
url(r'^search1/', 'search', name='search'),
url(r'^search2/', 'search',
    kwargs=dict(search_results_class=SearchResult2),
    name='help_search'),
```

When requesting search2 with the language changer in the template there is an [error instantiating kwargs in `DefaultLanguageChanger`](https://github.com/divio/django-cms/blob/master/menus/utils.py#L139) because there are mandatory parameters in the constructor of `SearchResult2`.

This behavior was introduced with https://github.com/divio/django-cms/issues/3142

Everything works fine when commenting out [the line that modifies `view.kwargs`](https://github.com/divio/django-cms/blob/master/menus/utils.py#L139). The same probably applies to the args processing.

While we can easily work around the problem, the kwargs instantiation doesn't make too much sense to me. 

BTW [Django docs](https://docs.djangoproject.com/en/dev/topics/http/urls/#views-extra-options) don't mention any constraints on args and kwargs. 



Discussion:
#3142 fixes a similar issue when passing forms class as kw arguments (for example for auth_login functions)
A strategy that works in both cases is needed

Thanks for the fast response.

I don't get the error you were getting because my class doesn't specify a `__unicode__` method and can be passed to `force_text` without generating an error. 

IMO this looks like a bug (or at least undocumented behavior) in Django. I see no reason to coerce the arguments to unicode unless you need to interpolate those into the URL - which is not the case in both of our examples.

If Django CMS needs to work around that and support both of our use cases one could try to call `force_text` on the arguments and omit or replace the values in case of exceptions. However, this also doesn't feel right.

There is no clean solution for this AFAIK, though it looks like this has been fixed in Django 1.7
Working on a more stable fix, anyway

Il 18 agosto 2014 22:14:14 CEST, "Ludwig Hähne" notifications@github.com ha scritto:

> Thanks for the fast response.
> 
> I don't get the error you were getting because my class doesn't specify
> a `__unicode__` method and can be passed to `force_text` without
> generating an error. 
> 
> IMO this looks like a bug (or at least undocumented behavior) in
> Django. I see no reason to coerce the arguments to unicode unless you
> need to interpolate those into the URL - which is not the case in both
> of our examples.
> 
> If Django CMS needs to work around that and support both of our use
> cases one could try to call `force_text` on the arguments and omit or
> replace the values in case of exceptions. However, this also don't feel
> right.
> 
> ---
> 
> Reply to this email directly or view it on GitHub:
> https://github.com/divio/django-cms/issues/3393#issuecomment-52548104

-- Inviato dal mio cellulare Android con K-9 Mail.


Resolving patch:
diff --git a/menus/utils.py b/menus/utils.py
--- a/menus/utils.py
+++ b/menus/utils.py
@@ -1,13 +1,15 @@
 # -*- coding: utf-8 -*-
 from __future__ import with_statement
+from contextlib import contextmanager
+import inspect
+import warnings
+from cms.models.titlemodels import Title
 from cms.utils import get_language_from_request
+from cms.utils.compat import DJANGO_1_6
 from cms.utils.i18n import force_language, hide_untranslated
 from django.conf import settings
 from django.core.urlresolvers import NoReverseMatch, reverse, resolve
-
-import warnings
-from cms.models.titlemodels import Title
-
+from django.utils import six
 
 
 def mark_descendants(nodes):
@@ -15,6 +17,7 @@ def mark_descendants(nodes):
         node.descendant = True
         mark_descendants(node.children)
 
+
 def cut_levels(nodes, level):
     """
     For cutting the nav_extender levels if you have a from_level in the navigation.
@@ -27,6 +30,7 @@ def cut_levels(nodes, level):
         result += cut_levels(node.children, level)
     return result
 
+
 def find_selected(nodes):
     """
     Finds a selected nav_extender node 
@@ -55,6 +59,7 @@ def get_absolute_url(self, language=None):
     """
     request._language_changer = func
 
+
 def language_changer_decorator(language_changer):
     """
     A decorator wrapper for set_language_changer.
@@ -126,22 +131,14 @@ def __call__(self, lang):
         elif view and not view.url_name in ('pages-details-by-slug', 'pages-root'):
             view_name = view.url_name
             if view.namespace:
-                "%s:%s" % (view.namespace, view_name)
+                view_name = "%s:%s" % (view.namespace, view_name)
             url = None
-            # every class-level argument is instantiated
-            # before reversing as reverse does not support
-            # classes as arguments
-            for idx, arg in enumerate(view.args):
-                if isinstance(arg, type):
-                    view.args[idx] = arg()
-            for key, arg in view.kwargs.items():
-                if isinstance(arg, type):
-                    view.kwargs[key] = arg()
             with force_language(lang):
-                try:
-                    url = reverse(view_name, args=view.args, kwargs=view.kwargs, current_app=view.app_name)
-                except NoReverseMatch:
-                    pass
+                with static_stringifier(view):  # This is a fix for Django < 1.7
+                    try:
+                        url = reverse(view_name, args=view.args, kwargs=view.kwargs, current_app=view.app_name)
+                    except NoReverseMatch:
+                        pass
             if url:
                 return url
         return '%s%s' % (self.get_page_path(lang), self.app_path)
@@ -158,3 +155,44 @@ def _wrapped(request, *args, **kwargs):
     _wrapped.__name__ = func.__name__
     _wrapped.__doc__ = func.__doc__
     return _wrapped
+
+
+@contextmanager
+def static_stringifier(view):
+    """
+    In Django < 1.7 reverse tries to convert to string the arguments without
+    checking whether they are classes or instances.
+
+    This context manager monkeypatches the __unicode__ method of each view
+    argument if it's a class definition to render it a static method.
+    Before leaving we undo the monkeypatching.
+    """
+    if DJANGO_1_6:
+        for idx, arg in enumerate(view.args):
+            if inspect.isclass(arg):
+                if hasattr(arg, '__unicode__'):
+                    @staticmethod
+                    def custom_str():
+                        return six.text_type(arg)
+                    arg._original = arg.__unicode__
+                    arg.__unicode__ = custom_str
+                view.args[idx] = arg
+        for key, arg in view.kwargs.items():
+            if inspect.isclass(arg):
+                if hasattr(arg, '__unicode__'):
+                    @staticmethod
+                    def custom_str():
+                        return six.text_type(arg)
+                    arg._original = arg.__unicode__
+                    arg.__unicode__ = custom_str
+                view.kwargs[key] = arg
+    yield
+    if DJANGO_1_6:
+        for idx, arg in enumerate(view.args):
+            if inspect.isclass(arg):
+                if hasattr(arg, '__unicode__'):
+                    arg.__unicode__ = arg._original
+        for key, arg in view.kwargs.items():
+            if inspect.isclass(arg):
+                if hasattr(arg, '__unicode__'):
+                    arg.__unicode__ = arg._original
\ No newline at end of file


Test patch:
diff --git a/cms/test_utils/project/sampleapp/forms.py b/cms/test_utils/project/sampleapp/forms.py
--- a/cms/test_utils/project/sampleapp/forms.py
+++ b/cms/test_utils/project/sampleapp/forms.py
@@ -8,3 +8,8 @@ class LoginForm(AuthenticationForm):
 
 class LoginForm2(AuthenticationForm):
     pass
+
+
+class LoginForm3(AuthenticationForm):
+    def __init__(self, argument, request=None, *args, **kwargs):
+        super(LoginForm3, self).__init__(request, *args, **kwargs)
diff --git a/cms/test_utils/project/sampleapp/ns_urls.py b/cms/test_utils/project/sampleapp/ns_urls.py
--- a/cms/test_utils/project/sampleapp/ns_urls.py
+++ b/cms/test_utils/project/sampleapp/ns_urls.py
@@ -1,9 +1,11 @@
 from django.conf.urls import patterns, url
+from django.utils.translation import ugettext_lazy as _
 
 """
 Also used in cms.tests.ApphooksTestCase
 """
 
 urlpatterns = patterns('cms.test_utils.project.sampleapp.views',
-    url(r'^current-app/$', 'current_app', name='current-app' ),
+    url(r'^current-app/$', 'current_app', name='current-app'),
+    url(_('page'), 'current_app', name='translated-url'),
 )
diff --git a/cms/test_utils/project/urls.py b/cms/test_utils/project/urls.py
--- a/cms/test_utils/project/urls.py
+++ b/cms/test_utils/project/urls.py
@@ -6,7 +6,7 @@
 
 from cms.utils.compat.dj import is_installed
 from cms.utils.conf import get_cms_setting
-from cms.test_utils.project.sampleapp.forms import LoginForm, LoginForm2
+from cms.test_utils.project.sampleapp.forms import LoginForm, LoginForm2, LoginForm3
 
 
 admin.autodiscover()
@@ -27,6 +27,8 @@
                                  kwargs={'authentication_form': LoginForm2}),
                              url(r'^sample/login/$', 'django.contrib.auth.views.login',
                                  kwargs={'authentication_form': LoginForm}),
+                             url(r'^sample/login3/$', 'django.contrib.auth.views.login',
+                                 kwargs={'authentication_form': LoginForm3}),
                              url(r'^admin/', include(admin.site.urls)),
                              url(r'^example/$', 'cms.test_utils.project.placeholderapp.views.example_view'),
                              url(r'^plain_view/$', 'cms.test_utils.project.sampleapp.views.plain_view'),
diff --git a/cms/tests/apphooks.py b/cms/tests/apphooks.py
--- a/cms/tests/apphooks.py
+++ b/cms/tests/apphooks.py
@@ -15,6 +15,7 @@
 from cms.utils.compat.dj import get_user_model
 from cms.utils.conf import get_cms_setting
 from cms.utils.i18n import force_language
+from menus.utils import DefaultLanguageChanger
 
 
 APP_NAME = 'SampleApp'
@@ -319,6 +320,23 @@ def test_get_sub_page_for_apphook_with_implicit_current_app(self):
 
             apphook_pool.clear()
 
+    def test_default_language_changer_with_implicit_current_app(self):
+        with SettingsOverride(ROOT_URLCONF='cms.test_utils.project.second_urls_for_apphook_tests'):
+            titles = self.create_base_structure(NS_APP_NAME, ['en', 'de'], 'namespaced_app_ns')  # nopyflakes
+            self.reload_urls()
+            with force_language("en"):
+                path = reverse('namespaced_app_ns:translated-url')
+            request = self.get_request(path)
+            request.LANGUAGE_CODE = 'en'
+
+            url = DefaultLanguageChanger(request)('en')
+            self.assertEqual(url, path)
+
+            url = DefaultLanguageChanger(request)('de')
+            self.assertEqual(url, '/de%s' % path[3:].replace('/page', '/Seite'))
+
+            apphook_pool.clear()
+
     def test_get_i18n_apphook_with_explicit_current_app(self):
         with SettingsOverride(ROOT_URLCONF='cms.test_utils.project.second_urls_for_apphook_tests'):
             titles = self.create_base_structure(NS_APP_NAME, ['en', 'de'], 'instance_1')
@@ -350,9 +368,6 @@ def test_get_i18n_apphook_with_explicit_current_app(self):
                 reverse('namespaced_app_ns:current-app', current_app="instance_2")
                 reverse('namespaced_app_ns:current-app')
 
-
-
-
     def test_apphook_include_extra_parameters(self):
         with SettingsOverride(ROOT_URLCONF='cms.test_utils.project.second_urls_for_apphook_tests'):
             self.create_base_structure(NS_APP_NAME, ['en', 'de'], 'instance_1')
diff --git a/cms/tests/menu_utils.py b/cms/tests/menu_utils.py
--- a/cms/tests/menu_utils.py
+++ b/cms/tests/menu_utils.py
@@ -24,6 +24,9 @@ def test_reverse_in_changer(self):
         response = self.client.get('/en/sample/login_other/')
         self.assertContains(response, '<h1>/fr/sample/login_other/</h1>')
 
+        response = self.client.get('/en/sample/login3/')
+        self.assertContains(response, '<h1>/fr/sample/login3/</h1>')
+
     def test_simple_language_changer(self):
         func = self.get_simple_view()
         decorated_view = simple_language_changer(func)