Repository: django-cms/django-cms

Issue:
Relax url pk-regexes in FrontendEditableAdminMixin, PlaceholderAdminMixin
Currently frontend editing of models that have non-numeric primary keys is not possible.
`render_model` will throw NoReverseMatch in draft mode (see [1] for a post on the issue in Google Groups)

This PR fixes this for me. 

Unfortunately I'm too busy to setup the test machinery and add some tests just right now. 
Please tell me, if you'd be willing to merge this only if I add some tests.

[1] https://groups.google.com/forum/#!topic/django-cms/_Z_Lj_X4nCY



Discussion:

Resolving patch:
diff --git a/cms/admin/placeholderadmin.py b/cms/admin/placeholderadmin.py
--- a/cms/admin/placeholderadmin.py
+++ b/cms/admin/placeholderadmin.py
@@ -43,13 +43,14 @@ def get_urls(self):
         Register the url for the single field edit view
         """
         from django.conf.urls import patterns, url
+        from cms.urls import SLUG_REGEXP
 
         info = "%s_%s" % (self.model._meta.app_label, self.model._meta.module_name)
         pat = lambda regex, fn: url(regex, self.admin_site.admin_view(fn), name='%s_%s' % (info, fn.__name__))
 
         url_patterns = patterns(
             '',
-            pat(r'edit-field/([0-9]+)/([a-z\-]+)/$', self.edit_field),
+            pat(r'edit-field/(%s)/([a-z\-]+)/$' % SLUG_REGEXP, self.edit_field),
         )
         return url_patterns + super(FrontendEditableAdminMixin, self).get_urls()
 
@@ -125,6 +126,7 @@ def get_urls(self):
         Register the plugin specific urls (add/edit/copy/remove/move)
         """
         from django.conf.urls import patterns, url
+        from cms.urls import SLUG_REGEXP
 
         info = "%s_%s" % (self.model._meta.app_label, self.model._meta.module_name)
         pat = lambda regex, fn: url(regex, self.admin_site.admin_view(fn), name='%s_%s' % (info, fn.__name__))
@@ -133,9 +135,9 @@ def get_urls(self):
             '',
             pat(r'copy-plugins/$', self.copy_plugins),
             pat(r'add-plugin/$', self.add_plugin),
-            pat(r'edit-plugin/([0-9]+)/$', self.edit_plugin),
-            pat(r'delete-plugin/([0-9]+)/$', self.delete_plugin),
-            pat(r'clear-placeholder/([0-9]+)/$', self.clear_placeholder),
+            pat(r'edit-plugin/(%s)/$' % SLUG_REGEXP, self.edit_plugin),
+            pat(r'delete-plugin/(%s)/$' % SLUG_REGEXP, self.delete_plugin),
+            pat(r'clear-placeholder/(%s)/$' % SLUG_REGEXP, self.clear_placeholder),
             pat(r'move-plugin/$', self.move_plugin),
         )
         return url_patterns + super(PlaceholderAdminMixin, self).get_urls()
diff --git a/cms/urls.py b/cms/urls.py
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -6,11 +6,12 @@
 from cms.appresolver import get_app_patterns
 from cms.views import details
 
-
+# This is a constant, really, but must live here due to import order
+SLUG_REGEXP = '[0-9A-Za-z-_.//]+'
 if settings.APPEND_SLASH:
-    regex = r'^(?P<slug>[0-9A-Za-z-_.//]+)/$'
+    regexp = r'^(?P<slug>%s)/$' % SLUG_REGEXP
 else:
-    regex = r'^(?P<slug>[0-9A-Za-z-_.//]+)$'
+    regexp = r'^(?P<slug>%s)/$' % SLUG_REGEXP
 
 if apphook_pool.get_apphooks():
     # If there are some application urls, use special resolver,
@@ -20,6 +21,6 @@
     urlpatterns = []
 
 urlpatterns.extend([
-    url(regex, details, name='pages-details-by-slug'),
+    url(regexp, details, name='pages-details-by-slug'),
     url(r'^$', details, {'slug': ''}, name='pages-root'),
 ])


Test patch:
diff --git a/cms/test_utils/project/placeholderapp/admin.py b/cms/test_utils/project/placeholderapp/admin.py
--- a/cms/test_utils/project/placeholderapp/admin.py
+++ b/cms/test_utils/project/placeholderapp/admin.py
@@ -1,5 +1,6 @@
 from cms.admin.placeholderadmin import PlaceholderAdminMixin, FrontendEditableAdminMixin
-from cms.test_utils.project.placeholderapp.models import (Example1, MultilingualExample1, TwoPlaceholderExample)
+from cms.test_utils.project.placeholderapp.models import (Example1, MultilingualExample1,
+                                                          TwoPlaceholderExample, CharPksExample)
 from django.contrib import admin
 from hvad.admin import TranslatableAdmin
 
@@ -8,6 +9,10 @@ class ExampleAdmin(FrontendEditableAdminMixin, PlaceholderAdminMixin, admin.Mode
     frontend_editable_fields = ("char_1", "char_2")
 
 
+class CharPksAdmin(FrontendEditableAdminMixin, PlaceholderAdminMixin, admin.ModelAdmin):
+    frontend_editable_fields = ("char_1",)
+
+
 class TwoPlaceholderExampleAdmin(PlaceholderAdminMixin, admin.ModelAdmin):
     pass
 
@@ -18,5 +23,6 @@ class MultilingualAdmin(FrontendEditableAdminMixin, TranslatableAdmin,
 
 
 admin.site.register(Example1, ExampleAdmin)
+admin.site.register(CharPksExample, CharPksAdmin)
 admin.site.register(TwoPlaceholderExample, TwoPlaceholderExampleAdmin)
 admin.site.register(MultilingualExample1, MultilingualAdmin)
diff --git a/cms/test_utils/project/placeholderapp/migrations_django/0002_charpksexample.py b/cms/test_utils/project/placeholderapp/migrations_django/0002_charpksexample.py
new file mode 100644
--- /dev/null
+++ b/cms/test_utils/project/placeholderapp/migrations_django/0002_charpksexample.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import cms.models.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('cms', '0003_auto_20140926_2347'),
+        ('placeholderapp', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CharPksExample',
+            fields=[
+                ('char_1', models.CharField(max_length=255, verbose_name='char_1')),
+                ('slug', models.SlugField(max_length=255, serialize=False, verbose_name='char_1', primary_key=True)),
+                ('placeholder_1', cms.models.fields.PlaceholderField(related_name='charpk_p1', slotname='placeholder_1', editable=False, to='cms.Placeholder', null=True)),
+            ],
+            options={
+            },
+            bases=(models.Model,),
+        ),
+    ]
diff --git a/cms/test_utils/project/placeholderapp/models.py b/cms/test_utils/project/placeholderapp/models.py
--- a/cms/test_utils/project/placeholderapp/models.py
+++ b/cms/test_utils/project/placeholderapp/models.py
@@ -65,6 +65,12 @@ class DynamicPlaceholderSlotExample(models.Model):
     placeholder_2 = PlaceholderField(dynamic_placeholder_2, related_name='dynamic_pl_2')
 
 
+class CharPksExample(models.Model):
+    char_1 = models.CharField(u'char_1', max_length=255)
+    slug = models.SlugField(u'char_1', max_length=255, primary_key=True)
+    placeholder_1 = PlaceholderField('placeholder_1', related_name='charpk_p1')
+
+
 @python_2_unicode_compatible
 class MultilingualExample1(TranslatableModel):
     translations = TranslatedFields(
diff --git a/cms/test_utils/project/placeholderapp/urls.py b/cms/test_utils/project/placeholderapp/urls.py
--- a/cms/test_utils/project/placeholderapp/urls.py
+++ b/cms/test_utils/project/placeholderapp/urls.py
@@ -3,6 +3,7 @@
 
 urlpatterns = patterns('cms.test_utils.project.placeholderapp.views',
     url(r'^detail/(?P<pk>[0-9]+)/$', 'detail_view', name="example_detail"),
+    url(r'^detail_char/(?P<pk>[a-z]+)/$', 'detail_view_char', name="example_detail_char"),
     url(r'^detail/class/(?P<pk>[0-9]+)/$', ClassDetail.as_view(), name="example_class_detail"),
     url(r'^$', 'list_view', name="example_list"),
 )
diff --git a/cms/test_utils/project/placeholderapp/views.py b/cms/test_utils/project/placeholderapp/views.py
--- a/cms/test_utils/project/placeholderapp/views.py
+++ b/cms/test_utils/project/placeholderapp/views.py
@@ -3,8 +3,8 @@
 from django.template.base import Template
 from django.template.context import RequestContext
 from django.views.generic import DetailView
-from cms.test_utils.project.placeholderapp.models import (Example1,
-                                                          MultilingualExample1)
+from cms.test_utils.project.placeholderapp.models import (
+    Example1, MultilingualExample1, CharPksExample)
 from cms.utils import get_language_from_request
 
 
@@ -61,6 +61,13 @@ def detail_view(request, pk, template_name='detail.html', item_name="char_1",
                         template_string)
 
 
+def detail_view_char(request, pk, template_name='detail.html', item_name="char_1",
+                     template_string='',):
+    instance = CharPksExample.objects.get(pk=pk)
+    return _base_detail(request, instance, template_name, item_name,
+                        template_string)
+
+
 class ClassDetail(DetailView):
     model = Example1
     template_name = "detail.html"
\ No newline at end of file
diff --git a/cms/tests/admin.py b/cms/tests/admin.py
--- a/cms/tests/admin.py
+++ b/cms/tests/admin.py
@@ -309,7 +309,7 @@ def test_delete(self):
         page.publish('en')
         with self.login_user_context(admin_user):
             data = {'post': 'yes'}
-            with self.assertNumQueries(FuzzyInt(300, 397)):
+            with self.assertNumQueries(FuzzyInt(300, 405)):
                 response = self.client.post(URL_CMS_PAGE_DELETE % page.pk, data)
             self.assertRedirects(response, URL_CMS_PAGE)
 
@@ -326,7 +326,7 @@ def test_delete_diff_language(self):
         page.publish('en')
         with self.login_user_context(admin_user):
             data = {'post': 'yes'}
-            with self.assertNumQueries(FuzzyInt(300, 385)):
+            with self.assertNumQueries(FuzzyInt(300, 392)):
                 response = self.client.post(URL_CMS_PAGE_DELETE % page.pk, data)
             self.assertRedirects(response, URL_CMS_PAGE)
 
diff --git a/cms/tests/placeholder.py b/cms/tests/placeholder.py
--- a/cms/tests/placeholder.py
+++ b/cms/tests/placeholder.py
@@ -2,7 +2,6 @@
 from __future__ import with_statement
 import itertools
 import warnings
-from cms.utils.urlutils import admin_reverse
 
 from django.conf import settings
 from django.contrib import admin
@@ -35,10 +34,10 @@
 from cms.test_utils.project.fakemlng.models import Translations
 from cms.test_utils.project.objectpermissionsapp.models import UserObjectPermission
 from cms.test_utils.project.placeholderapp.models import (
+    DynamicPlaceholderSlotExample,
     Example1,
+    MultilingualExample1,
     TwoPlaceholderExample,
-    DynamicPlaceholderSlotExample,
-    MultilingualExample1
 )
 from cms.test_utils.testcases import CMSTestCase
 from cms.test_utils.util.context_managers import (SettingsOverride, UserLoginContext)
@@ -49,6 +48,7 @@
 from cms.utils.conf import get_cms_setting
 from cms.utils.placeholder import PlaceholderNoAction, MLNGPlaceholderActions, get_placeholder_conf
 from cms.utils.plugins import get_placeholders, assign_plugins
+from cms.utils.urlutils import admin_reverse
 
 
 class PlaceholderTestCase(CMSTestCase, UnittestCompatMixin):
diff --git a/cms/tests/toolbar.py b/cms/tests/toolbar.py
--- a/cms/tests/toolbar.py
+++ b/cms/tests/toolbar.py
@@ -2,10 +2,10 @@
 from __future__ import with_statement
 import datetime
 import re
-from cms.utils.urlutils import admin_reverse
 
-from django.template.defaultfilters import truncatewords
+from django.contrib import admin
 from django.contrib.auth.models import AnonymousUser, Permission
+from django.template.defaultfilters import truncatewords
 from django.test import TestCase
 from django.test.client import RequestFactory
 from django.utils.functional import lazy
@@ -19,16 +19,17 @@
 from cms.toolbar.items import (ToolbarAPIMixin, LinkItem, ItemSearchResult,
                                Break, SubMenu, AjaxItem)
 from cms.toolbar.toolbar import CMSToolbar
-from cms.test_utils.project.placeholderapp.models import (Example1,
+from cms.test_utils.project.placeholderapp.models import (Example1, CharPksExample,
                                                           MultilingualExample1)
-from cms.test_utils.project.placeholderapp.views import (detail_view,
+from cms.test_utils.project.placeholderapp.views import (detail_view, detail_view_char,
                                                          detail_view_multi,
                                                          detail_view_multi_unfiltered)
 from cms.test_utils.testcases import (SettingsOverrideTestCase,
                                       URL_CMS_PAGE_ADD, URL_CMS_PAGE_CHANGE)
-from cms.test_utils.util.context_managers import SettingsOverride
+from cms.test_utils.util.context_managers import SettingsOverride, UserLoginContext
 from cms.utils.compat import DJANGO_1_4
 from cms.utils.conf import get_cms_setting
+from cms.utils.urlutils import admin_reverse
 from cms.views import details
 
 
@@ -1250,6 +1251,83 @@ def test_edit_page(self):
             '<div class="cms_plugin cms_plugin-cms-page-changelist-%s cms_render_model cms_render_model_block"><h3>Menu</h3></div>' % page.pk)
 
 
+class CharPkFrontendPlaceholderAdminTest(ToolbarTestBase):
+
+    def get_admin(self):
+        admin.autodiscover()
+        return admin.site._registry[CharPksExample]
+
+    def test_url_char_pk(self):
+        """
+        Tests whether the frontend admin matches the edit_fields url with alphanumeric pks
+        """
+        ex = CharPksExample(
+            char_1='one',
+            slug='some-Special_slug_123',
+        )
+        ex.save()
+        superuser = self.get_superuser()
+        with UserLoginContext(self, superuser):
+            response = self.client.get(admin_reverse('placeholderapp_charpksexample_edit_field', args=(ex.pk, 'en')),
+                                       data={'edit_fields': 'char_1'})
+            # if we get a response pattern matches
+            self.assertEqual(response.status_code, 200)
+
+    def test_url_numeric_pk(self):
+        """
+        Tests whether the frontend admin matches the edit_fields url with numeric pks
+        """
+        ex = Example1(
+            char_1='one',
+            char_2='two',
+            char_3='tree',
+            char_4='four'
+        )
+        ex.save()
+        superuser = self.get_superuser()
+        with UserLoginContext(self, superuser):
+            response = self.client.get(admin_reverse('placeholderapp_example1_edit_field', args=(ex.pk, 'en')),
+                                       data={'edit_fields': 'char_1'})
+            # if we get a response pattern matches
+            self.assertEqual(response.status_code, 200)
+
+    def test_view_char_pk(self):
+        """
+        Tests whether the admin urls triggered when the toolbar is active works
+        (i.e.: no NoReverseMatch is raised) with alphanumeric pks
+        """
+        page = create_page('Test', 'col_two.html', 'en', published=True)
+        ex = CharPksExample(
+            char_1='one',
+            slug='some-Special_slug_123',
+        )
+        ex.save()
+        superuser = self.get_superuser()
+        request = self.get_page_request(page, superuser, edit=True)
+        response = detail_view_char(request, ex.pk)
+        # if we get a response pattern matches
+        self.assertEqual(response.status_code, 200)
+
+    def test_view_numeric_pk(self):
+        """
+        Tests whether the admin urls triggered when the toolbar is active works
+        (i.e.: no NoReverseMatch is raised) with numeric pks
+        """
+        page = create_page('Test', 'col_two.html', 'en', published=True)
+        ex = Example1(
+            char_1='one',
+            char_2='two',
+            char_3='tree',
+            char_4='four'
+        )
+        ex.save()
+        superuser = self.get_superuser()
+        request = self.get_page_request(page, superuser, edit=True)
+        response = detail_view(request, ex.pk)
+        # if we get a response pattern matches
+        self.assertEqual(response.status_code, 200)
+
+
 class ToolbarAPITests(TestCase):
     def test_find_item(self):
         api = ToolbarAPIMixin()