Repository: django-cms/django-cms

Issue:
CMSAttachMenu failing to register after 3.0.12
I noticed that after upgrading from 3.0.10 to 3.0.12 that a CMSAttachMenu was not registering on a page. It would periodically appear and disappear. In the advanced settings sometimes I would see zero, one, or two options for the same menu. I only have one CMSAttachMenu menu defined in a single app in my project. I attempted to delete and recreate the page to no avail. I noticed in the database, if there was only one option available in advanced settings, the page record showed `UserMenu` for `navigation_extenders`. If there were two options, it would be `UserMenu:190` or `UserMenu:193` depending which option I selected.

Ultimately I reverted to 3.0.10 and the issue went away.

My menu class:

``` python
from menus.base import NavigationNode
from cms.menu_bases import CMSAttachMenu
from menus.menu_pool import menu_pool
from django.utils.translation import ugettext_lazy as _

class UserMenu(CMSAttachMenu):

    name = _('User Menu')

    def get_nodes(self, request):
        nodes = []

        menu_id = 1
        root_id = menu_id

        # root node
        greeting = 'Hello, {0}'.format(request.user.get_full_name() or request.user) if request.user.is_authenticated() else 'Log in'
        title = '<i class="fa fa-user hidden-xs hidden-md hidden-lg"></i><span class="hidden-sm">{0}</span>'
        n = NavigationNode(title.format(greeting), "/user/", menu_id)
        nodes.append(n)

        # anonymous menus
        menu_id += 1
        n = NavigationNode(_('Log in'), "/login/", menu_id, root_id, attr={'visible_for_authenticated':False})
        nodes.append(n)

        menu_id += 1
        n = NavigationNode(_('Register'), "/register/", menu_id, root_id, attr={'visible_for_authenticated':False})
        nodes.append(n)

        menu_id += 1
        n = NavigationNode(_('Help'), "/help/ticket/new/guest/", menu_id, root_id, attr={'visible_for_authenticated':False})
        nodes.append(n)

        # authenticated menus
        menu_id += 1
        dashboard_id = menu_id
        n = NavigationNode(_('Dashboard'), "/user/dashboard/", menu_id, root_id, attr={'visible_for_anonymous':False})
        nodes.append(n)

        menu_id += 1
        n = NavigationNode(_('Log out'), "/logout/", menu_id, root_id, attr={'visible_for_anonymous':False})
        nodes.append(n)

        # user section dashboard sub-menu
        menu_id += 1
        n = NavigationNode(_('Dashboard'), "/user/dashboard/", menu_id, dashboard_id, attr={'visible_for_anonymous':False})
        nodes.append(n)

        menu_id += 1
        n = NavigationNode(_('Projects'), "/user/projects/", menu_id, dashboard_id, attr={'visible_for_anonymous':False})
        nodes.append(n)

        menu_id += 1
        n = NavigationNode(_('Help Desk'), "/user/help/", menu_id, dashboard_id, attr={'visible_for_anonymous':False})
        nodes.append(n)

        menu_id += 1
        n = NavigationNode(_('Profile'), "/user/profile/", menu_id, dashboard_id, attr={'visible_for_anonymous':False})
        nodes.append(n)

        return nodes

menu_pool.register_menu(UserMenu)
```



Discussion:

Resolving patch:
diff --git a/menus/menu_pool.py b/menus/menu_pool.py
--- a/menus/menu_pool.py
+++ b/menus/menu_pool.py
@@ -111,7 +111,7 @@ def _expand_menus(self):
         expanded_menus = {}
         for menu_class_name, menu_cls in self.menus.items():
             # In order to be eligible for "expansion", the menu_cls must, in
-            # fact, be an intantiable class. We are lenient about this here,
+            # fact, be an instantiable class. We are lenient about this here,
             # though, because the CMS has previously allowed attaching
             # CMSAttachMenu's as objects rather than classes.
             if isinstance(menu_cls, Menu):
@@ -121,14 +121,20 @@ def _expand_menus(self):
                 expanded_menus[menu_class_name] = menu_cls
             elif hasattr(menu_cls, "get_instances"):
                 # It quacks like a CMSAttachMenu, expand away!
-                for instance in menu_cls.get_instances():
-                    namespace = "{0}:{1}".format(
-                        menu_class_name, instance.pk)
-                    menu_inst = menu_cls()
-                    menu_inst.instance = instance
-                    expanded_menus[namespace] = menu_inst
+                # If a menu exists but has no instances,
+                # it's included in the available menus as is
+                instances = menu_cls.get_instances()
+                if not instances:
+                    expanded_menus[menu_class_name] = menu_cls()
+                else:
+                    for instance in instances:
+                        namespace = "{0}:{1}".format(
+                            menu_class_name, instance.pk)
+                        menu_inst = menu_cls()
+                        menu_inst.instance = instance
+                        expanded_menus[namespace] = menu_inst
             elif hasattr(menu_cls, "get_nodes"):
-                # This is another type of Menu, cannot be expended, but must be
+                # This is another type of Menu, cannot be expanded, but must be
                 # instantiated, none-the-less.
                 expanded_menus[menu_class_name] = menu_cls()
             else:


Test patch:
diff --git a/cms/test_utils/project/sampleapp/menu.py b/cms/test_utils/project/sampleapp/menu.py
--- a/cms/test_utils/project/sampleapp/menu.py
+++ b/cms/test_utils/project/sampleapp/menu.py
@@ -5,6 +5,7 @@
 from menus.base import Menu, NavigationNode
 from menus.menu_pool import menu_pool
 
+
 class SampleAppMenu(Menu):
     
     def get_nodes(self, request):
@@ -27,8 +28,10 @@ def get_nodes(self, request):
     
 menu_pool.register_menu(SampleAppMenu)
 
+
 class StaticMenu(CMSAttachMenu):
     name = _("Static Menu")
+
     def get_nodes(self, request):
         nodes = []
         n = NavigationNode('static root page', "/fresh/", 1)
@@ -42,9 +45,11 @@ def get_nodes(self, request):
         return nodes
 
 menu_pool.register_menu(StaticMenu)
-    
+
+
 class StaticMenu2(CMSAttachMenu):
     name = _("Static Menu2")
+
     def get_nodes(self, request):
         nodes = []
         n = NavigationNode('static2 root page', "/fresh/", 1)
@@ -57,5 +62,4 @@ def get_nodes(self, request):
         nodes.append(n4)
         return nodes
 
-menu_pool.register_menu(StaticMenu2)
-    
+menu_pool.register_menu(StaticMenu2)
\ No newline at end of file
diff --git a/cms/tests/menu.py b/cms/tests/menu.py
--- a/cms/tests/menu.py
+++ b/cms/tests/menu.py
@@ -7,7 +7,7 @@
 from django.contrib.sites.models import Site
 from django.template import Template, TemplateSyntaxError
 from django.utils.translation import activate
-from menus.base import NavigationNode
+from menus.base import NavigationNode, Menu
 from menus.menu_pool import menu_pool, _build_nodes_inner_for_one_menu
 from menus.models import CacheKey
 from menus.utils import mark_descendants, find_selected, cut_levels
@@ -18,6 +18,7 @@
 from cms.models.permissionmodels import GlobalPagePermission, PagePermission
 from cms.test_utils.fixtures.menus import (MenusFixture, SubMenusFixture, 
     SoftrootFixture, ExtendedMenusFixture)
+from cms.test_utils.project.sampleapp.menu import StaticMenu, StaticMenu2
 from cms.test_utils.testcases import SettingsOverrideTestCase
 from cms.test_utils.util.context_managers import (SettingsOverride,
     LanguageOverride)
@@ -58,6 +59,77 @@ def tearDown(self):
     def get_page(self, num):
         return Page.objects.public().get(title_set__title='P%s' % num)
 
+
+class MenuDiscoveryTest(ExtendedMenusFixture, SettingsOverrideTestCase):
+
+    def setUp(self):
+        super(MenuDiscoveryTest, self).setUp()
+        menu_pool.discovered = False
+        self.old_menu = menu_pool.menus
+        menu_pool.menus = {}
+        menu_pool.discover_menus()
+        menu_pool.register_menu(StaticMenu)
+        menu_pool.register_menu(StaticMenu2)
+
+    def tearDown(self):
+        menu_pool.menus = self.old_menu
+        menu_pool._expanded = False
+        super(MenuDiscoveryTest, self).tearDown()
+
+    def test_menu_types_expansion_basic(self):
+        request = self.get_request('/')
+
+        menu_pool.discover_menus()
+        self.assertFalse(menu_pool._expanded)
+        for key, menu in menu_pool.menus.items():
+            self.assertTrue(issubclass(menu, Menu))
+        defined_menus = len(menu_pool.menus)
+
+        # Testing expansion after get_nodes
+        menu_pool.get_nodes(request)
+        self.assertTrue(menu_pool._expanded)
+        for key, menu in menu_pool.menus.items():
+            self.assertTrue(isinstance(menu, Menu))
+        self.assertEqual(defined_menus, len(menu_pool.menus))
+
+    def test_menu_expanded(self):
+        menu_pool.discovered = False
+        menu_pool.discover_menus()
+
+        with SettingsOverride(ROOT_URLCONF='cms.test_utils.project.urls_for_apphook_tests'):
+            request = self.get_request('/')
+
+            page = create_page("apphooked-page", "nav_playground.html", "en",
+                               published=True, apphook="SampleApp",
+                               navigation_extenders='StaticMenu')
+
+            menu_pool._expanded = False
+            self.assertFalse(menu_pool._expanded)
+            self.assertTrue(menu_pool.discovered)
+            menu_pool.get_nodes(request)
+
+            self.assertTrue(menu_pool._expanded)
+            self.assertTrue(menu_pool.discovered)
+            menu_pool.get_nodes(request)
+
+            self.assertTrue(menu_pool._expanded)
+
+            # Counts the number of StaticMenu (which is expanded) and StaticMenu2
+            # (which is not) and checks the keyname for the StaticMenu instances
+            static_menus = 2
+            static_menus_2 = 1
+            for key, menu in menu_pool.menus.items():
+                if key.startswith('StaticMenu:'):
+                    static_menus -= 1
+                    self.assertTrue(key.endswith(str(page.get_public_object().pk)) or key.endswith(str(page.get_draft_object().pk)))
+
+                if key == 'StaticMenu2':
+                    static_menus_2 -= 1
+
+            self.assertEqual(static_menus, 0)
+            self.assertEqual(static_menus_2, 0)
+
+
 class ExtendedFixturesMenuTests(ExtendedMenusFixture, BaseMenuTest):
     """
     Tree from fixture:
@@ -251,7 +323,6 @@ def test_only_level_zero(self):
         for node in nodes:
             self.assertEqual(len(node.children), 0)
 
-
     def test_only_level_one(self):
         context = self.get_context()
         # test standard show_menu
@@ -262,7 +333,6 @@ def test_only_level_one(self):
         for node in nodes:
             self.assertEqual(len(node.children), 0)
 
-
     def test_only_level_one_active(self):
         context = self.get_context()
         # test standard show_menu