Repository: django-cms/django-cms

Issue:
Language fallbacks broken for nested plugins
This one has stretched my tiny little mind almost to breaking point.

I have a site with 2 languages, English and French. When someone is browsing the French site and requests a page for which there isn't a French translation, I'd like to return the English page (without redirecting).

This works well enough, except when it comes to nested plugins. For example, if you have a text plugin directly in a placeholder, it works - however if you have a text plugin inside a style plugin, or a multi-column plugin, the child plugin (the text) is never rendered in language fallback mode.

Here is why I think that is:

The function `cms.utils.plugins.assign_plugins` iterates over a placeholder for the request language and builds up a list of plugins. If it can't find any plugins for a given language, it'll recursively look for plugins in the fallback language (if any).

It then assigns the `placeholder._plugins_cache` attribute with whatever plugins it finds. however, due to the recursion, the fallback language `_plugins_cache` list is always clobbered by the request language.

I think I've solved this by making `assign_plugins` check if the `_plugins_cache` attribute exists first, (i.e., if it was populated by the fallback language call), and if so, don't clobber it:

```
    for placeholder in placeholders:
        if not hasattr(placeholder, '_plugins_cache'):
            setattr(placeholder, '_plugins_cache', list(groups.get(placeholder.pk, [])))
```

Although I'm not sure if this is the correct approach or if I'm breaking something else by doing this.



Discussion:

Resolving patch:
diff --git a/cms/utils/plugins.py b/cms/utils/plugins.py
--- a/cms/utils/plugins.py
+++ b/cms/utils/plugins.py
@@ -200,7 +200,7 @@ def requires_reload(action, plugins):
     return False
 
 
-def assign_plugins(request, placeholders, template, lang=None, no_fallback=False):
+def assign_plugins(request, placeholders, template, lang=None, is_fallback=False):
     """
     Fetch all plugins for the given ``placeholders`` and
     cast them down to the concrete instances in one query
@@ -214,10 +214,11 @@ def assign_plugins(request, placeholders, template, lang=None, no_fallback=False
     qs = get_cmsplugin_queryset(request).filter(placeholder__in=placeholders, language=request_lang).order_by(
         'placeholder', 'tree_id', 'level', 'position')
     plugins = list(qs)
+    fallbacks = defaultdict(list)
     # If no plugin is present in the current placeholder we loop in the fallback languages
     # and get the first available set of plugins
 
-    if not no_fallback:
+    if not is_fallback:
         for placeholder in placeholders:
             found = False
             for plugin in plugins:
@@ -229,22 +230,25 @@ def assign_plugins(request, placeholders, template, lang=None, no_fallback=False
             elif placeholder and get_placeholder_conf("language_fallback", placeholder.slot, template, False):
                 if hasattr(request, 'toolbar') and request.toolbar.edit_mode:
                     continue
-                fallbacks = get_fallback_languages(lang)
-                for fallback_language in fallbacks:
-                    assign_plugins(request, [placeholder], template, fallback_language, no_fallback=True)
+                fallback_languages = get_fallback_languages(lang)
+                for fallback_language in fallback_languages:
+                    assign_plugins(request, [placeholder], template, fallback_language, is_fallback=True)
                     fallback_plugins = placeholder._plugins_cache
                     if fallback_plugins:
-                        plugins += fallback_plugins
+                        fallbacks[placeholder.pk] += fallback_plugins
                         break
-    # If no plugin is present, create default plugins if enabled)
+    # These placeholders have no fallback
+    non_fallback_phs = [ph for ph in placeholders if ph.pk not in fallbacks]
+    # If no plugin is present in non fallback placeholders, create default plugins if enabled)
     if not plugins:
-        plugins = create_default_plugins(request, placeholders, template, lang)
-    plugin_list = downcast_plugins(plugins, placeholders)
+        plugins = create_default_plugins(request, non_fallback_phs, template, lang)
+    plugin_list = downcast_plugins(plugins, non_fallback_phs)
     # split the plugins up by placeholder
-    groups = dict((key, list(plugins)) for key, plugins in groupby(plugin_list, operator.attrgetter('placeholder_id')))
-
-    for group in groups:
-        groups[group] = build_plugin_tree(groups[group])
+    plugin_groups = dict((key, list(plugins)) for key, plugins in groupby(plugin_list, operator.attrgetter('placeholder_id')))
+    for group in plugin_groups:
+        plugin_groups[group] = build_plugin_tree(plugin_groups[group])
+    groups = fallbacks.copy()
+    groups.update(plugin_groups)
     for placeholder in placeholders:
         setattr(placeholder, '_plugins_cache', list(groups.get(placeholder.pk, [])))
 


Test patch:
diff --git a/cms/tests/multilingual.py b/cms/tests/multilingual.py
--- a/cms/tests/multilingual.py
+++ b/cms/tests/multilingual.py
@@ -38,7 +38,6 @@ class MultilingualTestCase(SettingsOverrideTestCase):
                           ('nav_playground.html', 'nav_playground.html')],
     }
 
-
     def test_create_page(self):
         """
         Test that a page can be created
@@ -122,7 +121,6 @@ def test_create_page(self):
             self.assertEqual(page.get_slug(fallback=False), page_data['slug'])
             self.assertEqual(page.get_title(fallback=False), page_data['title'])
     
-    
     def test_multilingual_page(self):
         TESTLANG = get_primary_lanaguage()
         TESTLANG2 = get_secondary_lanaguage()
@@ -212,7 +210,6 @@ def test_frontend_lang(self):
             response = self.client.get("/en/page4/")
             self.assertEqual(response.status_code, 404)
 
-
     def test_detail_view_404_when_no_language_is_found(self):
         page = create_page("page1", "nav_playground.html", "en")
         create_title("de", page.get_title(), page, slug=page.get_slug())
diff --git a/cms/tests/placeholder.py b/cms/tests/placeholder.py
--- a/cms/tests/placeholder.py
+++ b/cms/tests/placeholder.py
@@ -394,10 +394,10 @@ def push(self):
             },
         }
         with SettingsOverride(CMS_PLACEHOLDER_CONF=conf):
-            ## Deutsch page should have no text
             del(placeholder_de._plugins_cache)
             cache.clear()
             content_de = render_placeholder(placeholder_de, context_de)
+            ## Deutsch page should inherit english content
             self.assertRegexpMatches(content_de, "^en body$")
             context_de2 = NoPushPopContext()
             request = self.get_request(language="de", page=page_en)
@@ -417,6 +417,55 @@ def push(self):
             content_de = render_placeholder(placeholder_de, context_de)
             self.assertRegexpMatches(content_de, "^de body$")
 
+    def test_nested_plugins_language_fallback(self):
+        """ Tests language_fallback placeholder configuration for nested plugins"""
+        page_en = create_page('page_en', 'col_two.html', 'en')
+        title_de = create_title("de", "page_de", page_en)
+        placeholder_en = page_en.placeholders.get(slot='col_left')
+        placeholder_de = title_de.page.placeholders.get(slot='col_left')
+        link_en = add_plugin(placeholder_en, LinkPlugin, 'en', name='en name', url='http://example.com/en')
+        add_plugin(placeholder_en, TextPlugin, 'en',  target=link_en, body='en body')
+
+        class NoPushPopContext(SekizaiContext):
+            def push(self):
+                pass
+
+            pop = push
+
+        context_en = NoPushPopContext()
+        context_en['request'] = self.get_request(language="en", page=page_en)
+        context_de = NoPushPopContext()
+        context_de['request'] = self.get_request(language="de", page=page_en)
+
+        conf = {
+            'col_left': {
+                'language_fallback': True,
+            },
+        }
+        with SettingsOverride(CMS_PLACEHOLDER_CONF=conf):
+            content_de = render_placeholder(placeholder_de, context_de)
+            self.assertRegexpMatches(content_de, "<a href=\"http://example.com/en\">")
+            self.assertRegexpMatches(content_de, "en body")
+            context_de2 = NoPushPopContext()
+            request = self.get_request(language="de", page=page_en)
+            request.user = self.get_superuser()
+            request.toolbar = CMSToolbar(request)
+            request.toolbar.edit_mode = True
+            context_de2['request'] = request
+            del(placeholder_de._plugins_cache)
+            cache.clear()
+            content_de2 = render_placeholder(placeholder_de, context_de2)
+            self.assertFalse("en body" in content_de2)
+            # remove the cached plugins instances
+            del(placeholder_de._plugins_cache)
+            cache.clear()
+            # Then we add a plugin to check for proper rendering
+            link_de = add_plugin(placeholder_en, LinkPlugin, 'de', name='de name', url='http://example.com/de')
+            add_plugin(placeholder_en, TextPlugin, 'de',  target=link_de, body='de body')
+            content_de = render_placeholder(placeholder_de, context_de)
+            self.assertRegexpMatches(content_de, "<a href=\"http://example.com/de\">")
+            self.assertRegexpMatches(content_de, "de body")
+
     def test_plugins_non_default_language_fallback(self):
         """ Tests language_fallback placeholder configuration """
         page_en = create_page('page_en', 'col_two.html', 'en')