Repository: django-cms/django-cms

Issue:
Document the plugin order in INSTALLED_APPS
Since develop **all** plugins **must** be defined **after** `cms`
We need to add this to the documentation (both in the upgrade section and in the normal project setup.
Especially `djangocms_text_ckeditor` which still is documented to be installed before `cms`

Missing migration with Django 1.7 (DjangoCMS 3.0.6, up to 3.0.12)
Starting a project with Django 1.7 and DjangoCMS 3.0.6 will result in Django's `makemigrations` creating another migration file for the `cms` app. You can see the the created migration file here:

http://paste.awesom.eu/ZnyI

This gets annoying when creating custom plugins which then automatically depend on the locally created 0004 `cms` migration: It breaks deployment on a remote machine. My current fix is to manually edit the custom plugins' migration files to depend on the shipped 0003 `cms` migration.

I noticed that Django's migration system is more sensitive to changes than South, so could you please run `makemigrations` on all your cms apps (noticed a similar issue with `djangocms_link`) before the next release? Thanks!

Incorrect admin URL can cause a Python error
Try to reach a non-existent admin page, say http://localhost:8000/en/admin/cms/page/23/edit-plugin/408/ - you'll get a 404, as expected.

Try (say) http://localhost:8000/en/admin/cms/page/23/edit-plugin/408/edit-plugin/407/, and you'll get:

```
ValueError at /en/admin/cms/page/23/edit-plugin/408/edit-plugin/407/
invalid literal for int() with base 10: '408/edit-plugin/407'
```

I think that this should also return a 404.



Discussion:
I asked about `djangocms_text_ckeditor` on the [mailing list](https://groups.google.com/d/msg/django-cms-developers/On3-nFwgxeM/t3HwRhdu6kcJ) but didn't get a reply whether the recommendation to place before `cms` was valid documentation. What is happening now that requires all plugins to be listed after (sorry if I missed some announcement/changelog)? 

I'm still trying to wrap my head around the app registry in Django 1.7 (and realize y'all have semi-officially committed to supporting Django 1.6 for now) but can it be used to avoid the startup magic that requires finicky ordering of installed apps?

@jsma It took a while to find out the answer for the text plugin: the order is not relevant anymore.
It used to be relevant prior 3.0 for the plugin to be able to use its own templates instead of the CMS one (because it shares the template structure with the text plugin that used to be shipped with django CMS < 3).
Order **should** not be relevant anymore, but actually I find that Django 1.7 migrations are a bit tricky at resolving dependencies, so I'd say that a good advice would be cms first, then all the plugins

If the documented best practice then is to put all plugins _after_ `cms`, and if using a custom user model to put that _before_ `cms`, I think that at least resolves the issue I mentioned on the mailing list. I'll open a ticket to clarify the docs on the text plugin side of things.

This is a known issue and is resolved in the support/3.0.x branch. You'll need to install from there until 3.0.7 has been released.

```
pip install -e git+https://github.com/divio/django-cms.git@support/3.0.x#egg=django-cms
```

If you are using any of Divio's cms plugins or cmsplugin-filer, you might want to double check that they are all up to date as well. There were many challenges getting the new style migrations working correctly I believe we've got them all sorted out now. However, not everything has been given an official release yet so you may need to install from the git develop branches for some of these.

Sweet as, thanks!

Solved in current support/3.0.x branch about to be released as 3.0.7

Upgraded to Django-cms 3.0.7, still experiencing the same:
KeyError: u"Migration cmsplugin_filer_image.0001_initial dependencies reference nonexistent parent node (u'cms', u'0003_auto_20140926_2347')"

@spout said migration file is shipped. Have you followed Django 1.7 instructions? http://docs.django-cms.org/en/support-3.0.x/upgrade/3.0.6.html#django-1-7-support 

Thanks ! I think it was done, but it was commented in my settings. Solved.

I just installed 3.0.7, but it's still missing (at least) one migration:

```
$ pip show django-cms
---
Name: django-cms
Version: 3.0.7
```

```
$ ./manage.py makemigrations cms
Migrations for 'cms':
  0004_auto_20141201_1231.py:
    - Alter field plugin on aliaspluginmodel
    - Alter field parent on cmsplugin
```

See http://paste.awesom.eu/WcTb for the created file.

what's your settings? Actually the resulting migration is a noop as the fields are exactly the same as the ones created in the initial migration.
`develop.py makemigrations` which is the command used to create migrations for the CMS core does not report any further migration needed

The settings should be this: http://paste.awesom.eu/n5bA (edited to remove private data) .

I would like to add that i usually don't run `makemigrations` without a specific app following. I only stumbled upon this when bootstrapping the project. What does happen regularly though, is Django complaining about missing migrations when running `migrate` (which happens on deployment):

```
Your models have changes that are not yet reflected in a migration, and so won't be applied.
Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
```

I am also encountering this issue with Django 1.7 and Django-cms 3.0.9

What's the resulting code of the migration?

``` python
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import models, migrations


class Migration(migrations.Migration):

    dependencies = [
        ('cms', '0003_auto_20140926_2347'),
    ]

    operations = [
        migrations.AlterField(
            model_name='cmsplugin',
            name='plugin_type',
            field=models.CharField(verbose_name='plugin_name', max_length=50, editable=False, db_index=True),
            preserve_default=True,
        ),
        migrations.AlterField(
            model_name='title',
            name='has_url_overwrite',
            field=models.BooleanField(default=False, verbose_name='has url overwrite', db_index=True, editable=False),
            preserve_default=True,
        ),
    ]
```

Tried on a fresh project started with nephila/djangocms-installer and can't reproduce this on 3.0.10.
Django 1.7 is quite sensitive and errs on the safe side generating migration with no real changes.
Possibly some third-party application that for some reason triggers django db migration into thinking a database schema change has occurred

The project I'm encountering this on was also created with djangocms-installer, and so far is quite simple. I believe I first encountered this error upon adding my first custom CMS plugin.

I updated my cms version to 3.0.10 and am still experiencing the error.

I'm experiencing the same issue on djangocms 3.0.10 running on django 1.7.4, but originating from the djangocms_blog plugin. 

```
KeyError: u"Migration djangocms_blog.0001_initial dependencies reference nonexistent parent node (u'cms', u'0003_auto_20140926_2347')"
```

That is a different, albeit similar, problem.

Is there any opposition to simply adding the `0004_auto` migration to the project?

I'm definitely not keen adding a migration which can't be reproduced on stock django CMS project: we need to fix the issue that triggers the new (unneeded) migration, instead

The verbose names were changed in the migrations so they are out of sync with the models. That is why there are spurious migrations. I have a fix coming in the next few seconds but read on for background.

In 3.0.10, the 0002 migration has `verbose_name='has URL overwrite'` which was changed in https://github.com/divio/django-cms/commit/89a95b7393119fdeecff9c16f9757482fdf46e00 but the model's verbose_name is 'has url overwrite' (all lower case).

https://github.com/divio/django-cms/commit/3ffd7e6e60ab2d2ac69431c70a8d4db45ede0b69 changed the verbose name for CMSPlugin.plugin_type field from 'plugin_name' to 'plugin name'.

@jsma thanks! I must be 1.7 migration blind: I think I checked three or four times the fields attribute without spotting the difference.

I've been bitten by this type of issue before, so a little extra sensitive to it ;) In fact, just had to fix some migrations on another project minutes ago.

Fixed by #3916



Resolving patch:
diff --git a/cms/admin/placeholderadmin.py b/cms/admin/placeholderadmin.py
--- a/cms/admin/placeholderadmin.py
+++ b/cms/admin/placeholderadmin.py
@@ -7,7 +7,7 @@
 from django.contrib.admin.util import get_deleted_objects
 from django.core.exceptions import PermissionDenied
 from django.db import router, transaction
-from django.http import (HttpResponse, HttpResponseBadRequest,
+from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseNotFound,
                          HttpResponseForbidden, HttpResponseRedirect)
 from django.shortcuts import render_to_response, get_object_or_404
 from django.template import RequestContext
@@ -329,7 +329,10 @@ def copy_plugins(self, request):
 
     @xframe_options_sameorigin
     def edit_plugin(self, request, plugin_id):
-        plugin_id = int(plugin_id)
+        try:
+            plugin_id = int(plugin_id)
+        except ValueError:
+            return HttpResponseNotFound(force_text(_("Plugin not found")))
         cms_plugin = get_object_or_404(CMSPlugin.objects.select_related('placeholder'), pk=plugin_id)
 
         instance, plugin_admin = cms_plugin.get_plugin_instance(self.admin_site)
diff --git a/cms/models/placeholdermodel.py b/cms/models/placeholdermodel.py
--- a/cms/models/placeholdermodel.py
+++ b/cms/models/placeholdermodel.py
@@ -108,7 +108,7 @@ def _get_permission(self, request, key):
         the key can be: 'add', 'change' or 'delete'. For each attached object
         permission has to be granted either on attached model or on attached object.
         """
-        if request.user.is_superuser:
+        if getattr(request, 'user', None) and request.user.is_superuser:
             return True
         if self.page:
             return self._get_object_permission(self.page, request, key)
@@ -116,6 +116,8 @@ def _get_permission(self, request, key):
             return self._get_object_permission(obj, request, key)
 
     def _get_object_permission(self, obj, request, key):
+        if not getattr(request, 'user', None):
+            return False
         opts = obj._meta
         perm_accessor = getattr(opts, 'get_%s_permission' % key)
         perm_code = '%s.%s' % (opts.app_label, perm_accessor())
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)
@@ -43,8 +46,10 @@ def get_nodes(self, request):
 
 menu_pool.register_menu(StaticMenu)
 
+
 class StaticMenu2(CMSAttachMenu):
     name = _("Static Menu2")
+
     def get_nodes(self, request):
         nodes = []
         n = NavigationNode('static2 root page', "/fresh/", 1)
diff --git a/cms/tests/admin.py b/cms/tests/admin.py
--- a/cms/tests/admin.py
+++ b/cms/tests/admin.py
@@ -14,7 +14,8 @@
 from django.contrib.auth.models import Permission, AnonymousUser
 from django.contrib.sites.models import Site
 from django.core.urlresolvers import reverse
-from django.http import (Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse, QueryDict)
+from django.http import (Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse,
+                         QueryDict, HttpResponseNotFound)
 from django.utils.datastructures import MultiValueDictKeyError
 from django.utils.encoding import force_text, smart_str
 from django.utils import timezone
@@ -1064,6 +1065,22 @@ def test_plugin_edit_requires_permissions(self):
         response = self.client.post(url, dict())
         self.assertEqual(response.status_code, HttpResponse.status_code)
 
+    def test_plugin_edit_wrong_url(self):
+        """User tries to edit a plugin using a random url. 404 response returned"""
+        plugin = self._create_plugin()
+        _, normal_guy = self._get_guys()
+
+        if get_user_model().USERNAME_FIELD == 'email':
+            self.client.login(username='test@test.com', password='test@test.com')
+        else:
+            self.client.login(username='test', password='test')
+
+        self._give_permission(normal_guy, Text, 'change')
+        url = '%s/edit-plugin/%s/' % (admin_reverse('cms_page_edit_plugin', args=[plugin.id]), plugin.id)
+        response = self.client.post(url, dict())
+        self.assertEqual(response.status_code, HttpResponseNotFound.status_code)
+        self.assertTrue("Plugin not found" in force_text(response.content))
+
     def test_plugin_remove_requires_permissions(self):
         """User tries to remove a plugin but has no permissions. He can remove the plugin after he got the permissions"""
         plugin = self._create_plugin()
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.template import Template, TemplateSyntaxError
 from django.test.utils import override_settings
 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
@@ -16,6 +16,7 @@
 from cms.menu import CMSMenu, get_visible_pages
 from cms.models import Page, ACCESS_PAGE_AND_DESCENDANTS
 from cms.models.permissionmodels import GlobalPagePermission, PagePermission
+from cms.test_utils.project.sampleapp.menu import StaticMenu, StaticMenu2
 from cms.test_utils.fixtures.menus import (MenusFixture, SubMenusFixture,
                                            SoftrootFixture, ExtendedMenusFixture)
 from cms.test_utils.testcases import CMSTestCase
@@ -57,6 +58,76 @@ def get_page(self, num):
         return Page.objects.public().get(title_set__title='P%s' % num)
 
 
+class MenuDiscoveryTest(ExtendedMenusFixture, CMSTestCase):
+
+    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 self.settings(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:
@@ -250,7 +321,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
@@ -261,7 +331,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
diff --git a/cms/tests/placeholder.py b/cms/tests/placeholder.py
--- a/cms/tests/placeholder.py
+++ b/cms/tests/placeholder.py
@@ -14,7 +14,7 @@
 from django.template import TemplateSyntaxError, Template
 from django.template.context import Context, RequestContext
 from django.template.loader import get_template
-from django.test import TestCase
+from django.test import TestCase, RequestFactory
 from django.test.utils import override_settings
 from django.utils.encoding import force_text
 from django.utils.numberformat import format
@@ -844,6 +844,13 @@ def push(self):
 
         context_en = NoPushPopContext()
 
+        # no user: no placeholders but no error either
+        factory = RequestFactory()
+        context_en['request'] = factory.get(page_en.get_absolute_url())
+        render_placeholder(ex.placeholder, context_en)
+        self.assertEqual(len(context_en['request'].placeholders), 0)
+        self.assertNotIn(ex.placeholder, context_en['request'].placeholders)
+
         # request.placeholders is populated for superuser
         context_en['request'] = self.get_request(language="en", page=page_en)
         context_en['request'].user = self.get_superuser()