Repository: django-cms/django-cms

Issue:
root_level in show_sub_menu
I end up creating a menu like this in just about every project. What I always need to do is print a sub_menu that starts after the root_level specified and not at the selected node.  This will include all ancestors, descendants of the selected node (up to root_level), plus every sibling of the selected node and all their descendants.

@superdmp and I discussed a menu like this before.  Does anybody else ever need to create menus this way?



Discussion:

Resolving patch:
diff --git a/menus/templatetags/menu_tags.py b/menus/templatetags/menu_tags.py
--- a/menus/templatetags/menu_tags.py
+++ b/menus/templatetags/menu_tags.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import with_statement
+import urllib
+
 from classytags.arguments import IntegerArgument, Argument, StringArgument
 from classytags.core import Options
 from classytags.helpers import InclusionTag
@@ -12,7 +14,7 @@
 from django.utils.translation import activate, get_language, ugettext
 from menus.menu_pool import menu_pool
 from menus.utils import DefaultLanguageChanger
-import urllib
+
 
 register = template.Library()
 
@@ -38,12 +40,14 @@ def cut_after(node, levels, removed):
             node.children.remove(removed_child)
         removed.extend(removed_local)
 
+
 def remove(node, removed):
     removed.append(node)
     if node.parent:
         if node in node.parent.children:
             node.parent.children.remove(node)
 
+
 def cut_levels(nodes, from_level, to_level, extra_inactive, extra_active):
     """
     cutting nodes away from menus
@@ -51,7 +55,7 @@ def cut_levels(nodes, from_level, to_level, extra_inactive, extra_active):
     final = []
     removed = []
     selected = None
-    for node in nodes: 
+    for node in nodes:
         if not hasattr(node, 'level'):
             # remove and ignore nodes that don't have level information
             remove(node, removed)
@@ -80,6 +84,7 @@ def cut_levels(nodes, from_level, to_level, extra_inactive, extra_active):
                 final.remove(node)
     return final
 
+
 def flatten(nodes):
     flat = []
     for node in nodes:
@@ -101,7 +106,7 @@ class ShowMenu(InclusionTag):
     """
     name = 'show_menu'
     template = 'menu/dummy.html'
-    
+
     options = Options(
         IntegerArgument('from_level', default=0, required=False),
         IntegerArgument('to_level', default=100, required=False),
@@ -112,7 +117,7 @@ class ShowMenu(InclusionTag):
         StringArgument('root_id', default=None, required=False),
         Argument('next_page', default=None, required=False),
     )
-    
+
     def get_context(self, context, from_level, to_level, extra_inactive,
                     extra_active, template, namespace, root_id, next_page):
         try:
@@ -120,11 +125,11 @@ def get_context(self, context, from_level, to_level, extra_inactive,
             request = context['request']
         except KeyError:
             return {'template': 'menu/empty.html'}
-        
+
         if next_page:
             children = next_page.children
-        else: 
-            #new menu... get all the data so we can save a lot of queries
+        else:
+        #new menu... get all the data so we can save a lot of queries
             nodes = menu_pool.get_nodes(request, namespace, root_id)
             if root_id: # find the root id and cut the nodes
                 id_nodes = menu_pool.get_nodes_by_attribute(nodes, "reverse_id", root_id)
@@ -140,18 +145,20 @@ def get_context(self, context, from_level, to_level, extra_inactive,
                     nodes = []
             children = cut_levels(nodes, from_level, to_level, extra_inactive, extra_active)
             children = menu_pool.apply_modifiers(children, request, namespace, root_id, post_cut=True)
-    
+
         try:
-            context.update({'children':children,
-                            'template':template,
-                            'from_level':from_level,
-                            'to_level':to_level,
-                            'extra_inactive':extra_inactive,
-                            'extra_active':extra_active,
-                            'namespace':namespace})
+            context.update({'children': children,
+                'template': template,
+                'from_level': from_level,
+                'to_level': to_level,
+                'extra_inactive': extra_inactive,
+                'extra_active': extra_active,
+                'namespace': namespace})
         except:
-            context = {"template":template}
+            context = {"template": template}
         return context
+
+
 register.tag(ShowMenu)
 
 
@@ -167,6 +174,8 @@ class ShowMenuBelowId(ShowMenu):
         Argument('namespace', default=None, required=False),
         Argument('next_page', default=None, required=False),
     )
+
+
 register.tag(ShowMenuBelowId)
 
 
@@ -174,17 +183,21 @@ class ShowSubMenu(InclusionTag):
     """
     show the sub menu of the current nav-node.
     - levels: how many levels deep
+    - root_level: the level to start the menu at
+    - nephews: the level of descendants of siblings (nephews) to show
     - template: template used to render the navigation
     """
     name = 'show_sub_menu'
     template = 'menu/dummy.html'
-    
+
     options = Options(
         IntegerArgument('levels', default=100, required=False),
+        IntegerArgument('root_level', default=None, required=False),
+        IntegerArgument('nephews', default=100, required=False),
         Argument('template', default='menu/sub_menu.html', required=False),
     )
-    
-    def get_context(self, context, levels, template):
+
+    def get_context(self, context, levels, root_level, nephews, template):
         try:
             # If there's an exception (500), default context_processors may not be called.
             request = context['request']
@@ -192,22 +205,45 @@ def get_context(self, context, levels, template):
             return {'template': 'menu/empty.html'}
         nodes = menu_pool.get_nodes(request)
         children = []
+        # adjust root_level so we cut before the specified level, not after
+        include_root = False
+        if root_level > 0:
+            root_level -= 1
+        elif root_level == 0:
+            include_root = True
         for node in nodes:
-            if node.selected:
+            if root_level is None:
+                if node.selected:
+                    # if no root_level specified, set it to the selected nodes level
+                    root_level = node.level
+                # is this the ancestor of current selected node at the root level?
+            is_root_ancestor = (node.ancestor and node.level == root_level)
+            # is a node selected on the root_level specified
+            root_selected = (node.selected and node.level == root_level)
+            if is_root_ancestor or root_selected:
                 cut_after(node, levels, [])
                 children = node.children
                 for child in children:
                     child.parent = None
-                children = menu_pool.apply_modifiers(children, request, post_cut=True)
+                    if child.sibling:
+                        cut_after(child, nephews, [])
+                    # if root_level was 0 we need to give the menu the entire tree
+                # not just the children
+                if include_root:
+                    children = menu_pool.apply_modifiers([node], request, post_cut=True)
+                else:
+                    children = menu_pool.apply_modifiers(children, request, post_cut=True)
         context.update({
-            'children':children,
-            'template':template,
-            'from_level':0,
-            'to_level':0,
-            'extra_inactive':0,
-            'extra_active':0
+            'children': children,
+            'template': template,
+            'from_level': 0,
+            'to_level': 0,
+            'extra_inactive': 0,
+            'extra_active': 0
         })
-        return context        
+        return context
+
+
 register.tag(ShowSubMenu)
 
 
@@ -220,7 +256,7 @@ class ShowBreadcrumb(InclusionTag):
     """
     name = 'show_breadcrumb'
     template = 'menu/dummy.html'
-    
+
     options = Options(
         Argument('start_level', default=0, required=False),
         Argument('template', default='menu/breadcrumb.html', required=False),
@@ -263,25 +299,31 @@ def get_context(self, context, start_level, template, only_visible):
             ancestors = ancestors[start_level:]
         else:
             ancestors = []
-        context.update({'ancestors':ancestors,
-                        'template': template})
+        context.update({'ancestors': ancestors,
+            'template': template})
         return context
+
+
 register.tag(ShowBreadcrumb)
 
 
 def _raw_language_marker(language, lang_code):
     return language
 
+
 def _native_language_marker(language, lang_code):
     with force_language(lang_code):
         return unicode(ugettext(language))
 
+
 def _current_language_marker(language, lang_code):
     return unicode(ugettext(language))
 
+
 def _short_language_marker(language, lang_code):
     return lang_code
 
+
 MARKERS = {
     'raw': _raw_language_marker,
     'native': _native_language_marker,
@@ -289,6 +331,7 @@ def _short_language_marker(language, lang_code):
     'short': _short_language_marker,
 }
 
+
 class LanguageChooser(InclusionTag):
     """
     Displays a language chooser
@@ -296,7 +339,7 @@ class LanguageChooser(InclusionTag):
     """
     name = 'language_chooser'
     template = 'menu/dummy.html'
-    
+
     options = Options(
         Argument('template', default=NOT_PROVIDED, required=False),
         Argument('i18n_mode', default='raw', required=False),
@@ -326,11 +369,13 @@ def get_context(self, context, template, i18n_mode):
             if user_is_staff or lang.get('public', True):
                 languages.append((lang['code'], marker(lang['name'], lang['code'])))
         context.update({
-            'languages':languages,
-            'current_language':current_lang,
-            'template':template,
+            'languages': languages,
+            'current_language': current_lang,
+            'template': template,
         })
         return context
+
+
 register.tag(LanguageChooser)
 
 
@@ -342,11 +387,11 @@ class PageLanguageUrl(InclusionTag):
     """
     name = 'page_language_url'
     template = 'cms/content.html'
-    
+
     options = Options(
         Argument('lang'),
     )
-    
+
     def get_context(self, context, lang):
         try:
             # If there's an exception (500), default context_processors may not be called.
@@ -360,4 +405,5 @@ def get_context(self, context, lang):
             url = DefaultLanguageChanger(request)(lang)
         return {'content': url}
 
+
 register.tag(PageLanguageUrl)


Test patch:
diff --git a/cms/test_utils/fixtures/menus.py b/cms/test_utils/fixtures/menus.py
--- a/cms/test_utils/fixtures/menus.py
+++ b/cms/test_utils/fixtures/menus.py
@@ -27,15 +27,46 @@ def create_fixtures(self):
             p1 = create_page('P1', published=True, in_navigation=True, **defaults)
             p4 = create_page('P4', published=True, in_navigation=True, **defaults)
             p6 = create_page('P6', published=True, in_navigation=False, **defaults)
-            p1 = Page.objects.get(pk=p1.pk)
             p2 = create_page('P2', published=True, in_navigation=True, parent=p1, **defaults)
-            create_page('P3', published=True, in_navigation=True, parent=p2, **defaults)
-            p4 = Page.objects.get(pk=p4.pk)
-            create_page('P5', published=True, in_navigation=True, parent=p4, **defaults)
-            p6 = Page.objects.get(pk=p6.pk)
-            create_page('P7', published=True, in_navigation=True, parent=p6, **defaults)
-            p6 = Page.objects.get(pk=p6.pk)
-            create_page('P8', published=True, in_navigation=True, parent=p6, **defaults)
+            p3 = create_page('P3', published=True, in_navigation=True, parent=p2, **defaults)
+            p5 = create_page('P5', published=True, in_navigation=True, parent=p4, **defaults)
+            p7 = create_page('P7', published=True, in_navigation=True, parent=p6, **defaults)
+            p8 = create_page('P8', published=True, in_navigation=True, parent=p6, **defaults)
+
+
+class ExtendedMenusFixture(object):
+    def create_fixtures(self):
+        """
+        Tree from fixture:
+            
+            + P1
+            | + P2
+            |   + P3
+            | + P9
+            |   + P10
+            |      + P11
+            + P4
+            | + P5
+            + P6 (not in menu)
+              + P7
+              + P8
+        """
+        defaults = {
+            'template': 'nav_playground.html',
+            'language': 'en',            
+        }
+        with SettingsOverride(CMS_MODERATOR=False, CMS_PERMISSION=False):
+            p1 = create_page('P1', published=True, in_navigation=True, **defaults)
+            p4 = create_page('P4', published=True, in_navigation=True, **defaults)
+            p6 = create_page('P6', published=True, in_navigation=False, **defaults)
+            p2 = create_page('P2', published=True, in_navigation=True, parent=p1, **defaults)
+            p3 = create_page('P3', published=True, in_navigation=True, parent=p2, **defaults)
+            p5 = create_page('P5', published=True, in_navigation=True, parent=p4, **defaults)
+            p7 = create_page('P7', published=True, in_navigation=True, parent=p6, **defaults)
+            p6 = create_page('P8', published=True, in_navigation=True, parent=p6, **defaults)
+            p9 = create_page('P9', published=True, in_navigation=True, parent=p1, **defaults)
+            p10 = create_page('P10', published=True, in_navigation=True, parent=p9, **defaults)
+            p11 = create_page('P11', published=True, in_navigation=True, parent=p10, **defaults)
 
 
 class SubMenusFixture(object):
@@ -60,15 +91,11 @@ def create_fixtures(self):
             p1 = create_page('P1', published=True, in_navigation=True, **defaults)
             p4 = create_page('P4', published=True, in_navigation=True, **defaults)
             p6 = create_page('P6', published=True, in_navigation=True, **defaults)
-            p1 = Page.objects.get(pk=p1.pk)
             p2 = create_page('P2', published=True, in_navigation=True, parent=p1, **defaults)
-            create_page('P3', published=True, in_navigation=True, parent=p2, **defaults)
-            p4 = Page.objects.get(pk=p4.pk)
-            create_page('P5', published=True, in_navigation=True, parent=p4, **defaults)
-            p6 = Page.objects.get(pk=p6.pk)
-            create_page('P7', published=True, in_navigation=False, parent=p6, **defaults)
-            p6 = Page.objects.get(pk=p6.pk)
-            create_page('P8', published=True, in_navigation=True, parent=p6, **defaults)
+            p3 = create_page('P3', published=True, in_navigation=True, parent=p2, **defaults)
+            p5 = create_page('P5', published=True, in_navigation=True, parent=p4, **defaults)
+            p7 = create_page('P7', published=True, in_navigation=False, parent=p6, **defaults)
+            p8 = create_page('P8', published=True, in_navigation=True, parent=p6, **defaults)
 
 
 class SoftrootFixture(object):
@@ -99,11 +126,8 @@ def create_fixtures(self):
             aaa = create_page('aaa', parent=root, **defaults)
             _111 = create_page('111', parent=aaa, **defaults)
             ccc = create_page('ccc', parent=_111, **defaults)
-            create_page('ddd', parent=ccc, **defaults)
-            aaa = Page.objects.get(pk=aaa.pk)
+            ddd = create_page('ddd', parent=ccc, **defaults)
             create_page('222', parent=aaa, **defaults)
-            root = Page.objects.get(pk=root.pk)
             bbb = create_page('bbb', parent=root, **defaults)
             create_page('333', parent=bbb, **defaults)
-            bbb = Page.objects.get(pk=bbb.pk)
             create_page('444', parent=bbb, **defaults)
diff --git a/cms/tests/menu.py b/cms/tests/menu.py
--- a/cms/tests/menu.py
+++ b/cms/tests/menu.py
@@ -5,8 +5,8 @@
 from cms.menu import CMSMenu, get_visible_pages
 from cms.models import Page
 from cms.models.permissionmodels import GlobalPagePermission, PagePermission
-from cms.test_utils.fixtures.menus import (MenusFixture, SubMenusFixture,
-    SoftrootFixture)
+from cms.test_utils.fixtures.menus import (MenusFixture, SubMenusFixture, 
+    SoftrootFixture, ExtendedMenusFixture)
 from cms.test_utils.testcases import SettingsOverrideTestCase
 from cms.test_utils.util.context_managers import (SettingsOverride,
     LanguageOverride)
@@ -56,11 +56,56 @@ def tearDown(self):
     def get_page(self, num):
         return Page.objects.public().get(title_set__title='P%s' % num)
 
+class ExtendedFixturesMenuTests(ExtendedMenusFixture, BaseMenuTest):
+    """
+    Tree from fixture:
+        
+        + P1
+        | + P2
+        |   + P3
+        | + P9
+        |   + P10
+        |      + P11
+        + P4
+        | + P5
+        + P6 (not in menu)
+          + P7
+          + P8
+    """
+    def get_page(self, num):
+        return Page.objects.public().get(title_set__title='P%s' % num)
+    
     def get_level(self, num):
         return Page.objects.public().filter(level=num)
 
     def get_all_pages(self):
         return Page.objects.public()
+    
+    def test_menu_failfast_on_invalid_usage(self):
+        context = self.get_context()
+        context['child'] = self.get_page(1)
+        # test standard show_menu
+        with SettingsOverride(DEBUG=True, TEMPLATE_DEBUG=True):
+            tpl = Template("{% load menu_tags %}{% show_menu 0 0 0 0 'menu/menu.html' child %}")
+            self.assertRaises(TemplateSyntaxError, tpl.render, context)
+
+    def test_show_submenu_nephews(self):
+        context = self.get_context(path=self.get_page(2).get_absolute_url())
+        tpl = Template("{% load menu_tags %}{% show_sub_menu 100 1 1 %}")
+        tpl.render(context)
+        nodes = context["children"]
+        # P2 is the selected node
+        self.assertTrue(nodes[0].selected) 
+        # Should include P10 but not P11 
+        self.assertEqual(len(nodes[1].children), 1)
+        self.assertFalse(nodes[1].children[0].children)
+
+        tpl = Template("{% load menu_tags %}{% show_sub_menu 100 1 %}")
+        tpl.render(context)
+        nodes = context["children"]
+        # should now include both P10 and P11
+        self.assertEqual(len(nodes[1].children), 1)
+        self.assertEqual(len(nodes[1].children[0].children), 1)
 
 
 class FixturesMenuTests(MenusFixture, BaseMenuTest):
@@ -76,6 +121,15 @@ class FixturesMenuTests(MenusFixture, BaseMenuTest):
           + P7
           + P8
     """
+    def get_page(self, num):
+        return Page.objects.public().get(title_set__title='P%s' % num)
+    
+    def get_level(self, num):
+        return Page.objects.public().filter(level=num)
+    
+    def get_all_pages(self):
+        return Page.objects.public()
+    
     def test_menu_failfast_on_invalid_usage(self):
         context = self.get_context()
         context['child'] = self.get_page(1)
@@ -234,6 +288,30 @@ def test_show_submenu(self):
         self.assertEqual(len(nodes), 1)
         self.assertEqual(len(nodes[0].children), 0)
 
+        context = self.get_context(path=self.get_page(3).get_absolute_url())
+        tpl = Template("{% load menu_tags %}{% show_sub_menu 100 1 %}")
+        tpl.render(context)
+        nodes = context["children"]
+        # P3 is the selected node
+        self.assertFalse(nodes[0].selected)
+        self.assertTrue(nodes[0].children[0].selected)
+        # top level node should be P2
+        self.assertEqual(nodes[0].get_absolute_url(), self.get_page(2).get_absolute_url())
+        # should include P3 as well
+        self.assertEqual(len(nodes[0].children), 1)
+        # but not P1 as it's at the root_level
+        self.assertEqual(nodes[0].parent, None)
+
+        context = self.get_context(path=self.get_page(2).get_absolute_url())
+        tpl = Template("{% load menu_tags %}{% show_sub_menu 100 0 %}")
+        tpl.render(context)
+        nodes = context["children"]
+        # P1 should be in the nav
+        self.assertEqual(nodes[0].get_absolute_url(), self.get_page(1).get_absolute_url())
+        # P2 is selected
+        self.assertTrue(nodes[0].children[0].selected)
+
+        
     def test_show_breadcrumb(self):
         context = self.get_context(path=self.get_page(3).get_absolute_url())
         tpl = Template("{% load menu_tags %}{% show_breadcrumb %}")