Repository: django-cms/django-cms

Issue:
App hook subpages don't work with Oracle
This query never returns anything on oracle:

title_qs.filter(application_urls__gt="").select_related()

This is found in appresolver.py. Changing the filter to `application_urls__isnull=False` works. Unfortunately, Oracle doesn't allow blank varchars, but converts them to NULL. It's best to not depend on blank Char values for cross-database compatibility.

Thanks!
Mike Johnson



Discussion:
The problem with Oracle is, I cannot test it. Therefore really supporting it is impossible for me.

The other issue is that your commits you've done are mixed with other fixes, so I cannot pull them.

Please at least provide a patch/commit I can pull if you want this fixed.

Sorry, that was just a branch I was keeping my changes in. I didn't know github was going to display them on the issue.

The issue is that the sql expression `application_urls > ''` doesn't work on Oracle. It never returns anything. In other databases, this is an easy way to exclude both blank and null values.

I'm still learning the code base so I don't know what the best fix would be. Typically there'd be some validation to disallow empty strings column and then IS NOT NULL (or `__isnull=False`) works for all databases.

The other benefit is that IS NOT NULL/IS NULL queries can use the index. Here is a quick test I did on Postgres to show the index is not used for `> ''`:

```
test=# create table pages(urls varchar(100));
CREATE TABLE
test=# create index i_pages on pages(urls);
CREATE INDEX
test=# insert into pages values ('test');
INSERT 0 1
test=# insert into pages values (null);
INSERT 0 1
test=# insert into pages values ('');
INSERT 0 1
test=# insert into pages select feature_name from information_schema.sql_features ;
INSERT 0 649
test=# VACUUM ANALYZE pages;
VACUUM
test=# explain analyze select * from pages where urls IS NULL;
                                                   QUERY PLAN                                                   
----------------------------------------------------------------------------------------------------------------
 Index Scan using i_pages on pages  (cost=0.00..8.27 rows=1 width=27) (actual time=0.016..0.017 rows=1 loops=1)
   Index Cond: (urls IS NULL)
 Total runtime: 0.041 ms
(3 rows)

test=# explain analyze select * from pages where urls > '';
                                             QUERY PLAN                                              
-----------------------------------------------------------------------------------------------------
 Seq Scan on pages  (cost=0.00..13.15 rows=651 width=27) (actual time=0.009..0.258 rows=650 loops=1)
   Filter: ((urls)::text > ''::text)
 Total runtime: 0.369 ms
(3 rows)
```

And on Oracle:

```
SQL> set autotrace on;
SQL> select count(*) from cms_title where application_urls IS NOT NULL;
| Id  | Operation    | Name           | Rows  | Bytes | Cost (%CPU)| Time     |
--------------------------------------------------------------------------------
|   0 | SELECT STATEMENT |            |     1 |     2 |     1   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE  |            |     1 |     2 |            |          |
|*  2 |   INDEX FULL SCAN| CMS_TITLE_23B1FC68 |     1 |     2      |     1  (0)| 00:00:01 |
```

It doesn't appear that blank values are valid data for the application_urls column. Hopefully this query change would not be a big deal.

Otherwise it could be a big headache for me since Oracle will silently convert blank VARCHAR fields to NULL. (Yes, I know. Freaking Oracle. But I have to use it, sadly.)


Resolving patch:
diff --git a/cms/appresolver.py b/cms/appresolver.py
--- a/cms/appresolver.py
+++ b/cms/appresolver.py
@@ -177,7 +177,7 @@ def get_app_patterns():
         hooked_applications = []
     
     # Loop over all titles with an application hooked to them
-    for title in title_qs.filter(application_urls__isnull=False).select_related():
+    for title in title_qs.exclude(application_urls="").select_related():
         if settings.CMS_FLAT_URLS:
             if title.language in home_slugs:
                 path = title.slug.split(home_slugs[title.language] + "/", 1)[-1]
diff --git a/cms/management/commands/cms.py b/cms/management/commands/cms.py
new file mode 100644
--- /dev/null
+++ b/cms/management/commands/cms.py
@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+import sys
+from optparse import make_option
+from django.core.management.base import BaseCommand, NoArgsCommand, LabelCommand, CommandError
+from cms.models.pluginmodel import CMSPlugin
+from cms.models.titlemodels import Title
+
+class SubcommandsCommand(BaseCommand):
+    subcommands = {}
+    command_name = ''
+
+    def __init__(self):
+        super(SubcommandsCommand, self).__init__()
+        for name, subcommand in self.subcommands.items():
+            subcommand.command_name = '%s %s' % (self.command_name, name)
+
+    def handle(self, *args, **options):
+        stderr = getattr(self, 'stderr', sys.stderr)
+        stdout = getattr(self, 'stdout', sys.stdout)
+        if len(args) > 0:
+            if args[0] in self.subcommands.keys():
+                handle_command = self.subcommands.get(args[0])()
+                handle_command.stdout = stdout
+                handle_command.stderr = stderr
+                handle_command.handle(*args[1:], **options)
+            else:
+                stderr.write("%r is not a valid subcommand for %r\n" % (args[0], self.command_name))
+                stderr.write("Available subcommands are:\n")
+                for subcommand in sorted(self.subcommands.keys()):
+                    stderr.write("  %r\n" % subcommand)
+                raise CommandError('Invalid subcommand %r for %r' % (args[0], self.command_name))
+        else:
+            stderr.write("%r must be called with at least one argument, it's subcommand.\n" % self.command_name)
+            stderr.write("Available subcommands are:\n")
+            for subcommand in sorted(self.subcommands.keys()):
+                stderr.write("  %r\n" % subcommand)
+            raise CommandError('No subcommand given for %r' % self.command_name)
+            
+class UninstallApphooksCommand(LabelCommand):
+    
+    args = "APPHOK_NAME"
+    label = 'apphook name (eg SampleApp)'
+    help = 'Uninstalls (sets to null) specified apphooks for all pages'
+    
+    def handle_label(self, label, **options):
+        queryset = Title.objects.filter(application_urls=label)
+        number_of_apphooks = queryset.count()
+
+        if number_of_apphooks > 0:
+            if options.get('interactive'):
+                confirm = raw_input("""
+You have requested to remove %d %r apphooks.
+Are you sure you want to do this?
+Type 'yes' to continue, or 'no' to cancel: """ % (number_of_apphooks, label))
+            else:
+                confirm = 'yes'
+            if confirm == 'yes':
+                queryset.update(application_urls=None)
+                self.stdout.write('%d %r apphooks uninstalled\n' % (number_of_apphooks, label))
+        else:
+            self.stdout.write('no %r apphooks found\n' % label)
+            
+class UninstallPluginsCommand(LabelCommand):
+
+    args = "PLUGIN_NAME"
+    label = 'plugin name (eg SamplePlugin)'
+    help = 'Uninstalls (deletes) specified plugins from the CMSPlugin model'
+    
+    def handle_label(self, label, **options):
+        queryset = CMSPlugin.objects.filter(plugin_type=label)
+        number_of_plugins = queryset.count()
+        
+        if number_of_plugins > 0:
+            if options.get('interactive'):
+                confirm = raw_input("""
+You have requested to remove %d %r plugins.
+Are you sure you want to do this?
+Type 'yes' to continue, or 'no' to cancel: """ % (number_of_plugins, label))
+            else:
+                confirm = 'yes'
+            queryset.delete()
+            self.stdout.write('%d %r plugins uninstalled\n' % (number_of_plugins, label))
+        else:
+            self.stdout.write('no %r plugins found\n' % label)            
+
+class UninstallCommand(SubcommandsCommand):
+    help = 'Uninstall commands'
+    subcommands = {
+        'apphooks': UninstallApphooksCommand,
+        'plugins': UninstallPluginsCommand
+    }
+    
+class ListApphooksCommand(NoArgsCommand):
+    
+    help = 'Lists all apphooks in pages'
+    def handle_noargs(self, **options):
+        urls = Title.objects.values_list("application_urls", flat=True)
+        for url in urls:
+            self.stdout.write(url+'\n')
+            
+class ListPluginsCommand(NoArgsCommand):
+
+    help = 'Lists all plugins in CMSPlugin'
+    def handle_noargs(self, **options):
+        plugins = CMSPlugin.objects.distinct().values_list("plugin_type", flat=True)
+        for plugin in plugins:
+            self.stdout.write(plugin+'\n')            
+    
+class ListCommand(SubcommandsCommand):
+    help = 'List commands'
+    subcommands = {
+        'apphooks': ListApphooksCommand,
+        'plugins': ListPluginsCommand
+    }
+    
+class Command(SubcommandsCommand):
+    
+    option_list = BaseCommand.option_list + (
+        make_option('--noinput', action='store_false', dest='interactive', default=True,
+        help='Tells django-cms to NOT prompt the user for input of any kind. '),
+    )
+
+    command_name = 'cms'
+    
+    help = 'Various django-cms commands'
+    subcommands = {
+        'uninstall': UninstallCommand,
+        'list': ListCommand,
+    }
\ No newline at end of file
diff --git a/cms/management/commands/publisher_publish.py b/cms/management/commands/publisher_publish.py
--- a/cms/management/commands/publisher_publish.py
+++ b/cms/management/commands/publisher_publish.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+from __future__ import absolute_import
 from django.conf import settings
 from django.core.management.base import NoArgsCommand, CommandError
 


Test patch:
diff --git a/cms/tests/__init__.py b/cms/tests/__init__.py
--- a/cms/tests/__init__.py
+++ b/cms/tests/__init__.py
@@ -30,6 +30,7 @@
 from cms.tests.toolbar import ToolbarTests
 from cms.tests.urlutils import UrlutilsTestCase
 from cms.tests.views import ViewTests
+from cms.tests.management import ManagementTestCase
 
 try:
     from cms.tests.javascript import JavascriptTestCase
diff --git a/cms/tests/apphooks.py b/cms/tests/apphooks.py
--- a/cms/tests/apphooks.py
+++ b/cms/tests/apphooks.py
@@ -23,6 +23,13 @@ def setUp(self):
         
         if APP_MODULE in sys.modules:
             del sys.modules[APP_MODULE]
+
+    def tearDown(self):
+        clear_app_resolvers()
+        clear_url_caches()
+
+        if APP_MODULE in sys.modules:
+            del sys.modules[APP_MODULE]
     
     def test_explicit_apphooks(self):
         """
diff --git a/cms/tests/management.py b/cms/tests/management.py
new file mode 100644
--- /dev/null
+++ b/cms/tests/management.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+from __future__ import with_statement
+from StringIO import StringIO
+from cms.api import create_page
+from cms.test_utils.testcases import CMSTestCase
+from cms.test_utils.util.context_managers import SettingsOverride
+from cms.api import create_page, add_plugin
+from cms.management.commands import cms
+from cms.models.pluginmodel import CMSPlugin
+from cms.models.titlemodels import Title
+from cms.models.placeholdermodel import Placeholder
+from cms.plugins.text.cms_plugins import TextPlugin
+
+APPHOOK = "SampleApp"
+PLUGIN = "TextPlugin"
+
+class ManagementTestCase(CMSTestCase):
+
+    def test_list_apphooks(self):
+        out = StringIO()
+        apps = ["cms", "menus", "sekizai", "project.sampleapp"]
+        with SettingsOverride(INSTALLED_APPS=apps):
+            create_page('Hello Title', "nav_playground.html", "en", apphook=APPHOOK)
+            self.assertEqual(Title.objects.filter(application_urls=APPHOOK).count(), 1)            
+            command = cms.Command()
+            command.stdout = out
+            command.handle("list", "apphooks", interactive=False)
+            self.assertEqual(out.getvalue(), "SampleApp\n")
+            
+    def test_uninstall_apphooks_without_apphook(self):
+        out = StringIO()
+        command = cms.Command()
+        command.stdout = out
+        command.handle("uninstall", "apphooks", APPHOOK, interactive=False)
+        self.assertEqual(out.getvalue(), "no 'SampleApp' apphooks found\n")
+
+    def test_uninstall_apphooks_with_apphook(self):
+        out = StringIO()
+        apps = ["cms", "menus", "sekizai", "project.sampleapp"]
+        with SettingsOverride(INSTALLED_APPS=apps):
+            create_page('Hello Title', "nav_playground.html", "en", apphook=APPHOOK)
+            self.assertEqual(Title.objects.filter(application_urls=APPHOOK).count(), 1)
+            command = cms.Command()
+            command.stdout = out
+            command.handle("uninstall", "apphooks", APPHOOK, interactive=False)
+            self.assertEqual(out.getvalue(), "1 'SampleApp' apphooks uninstalled\n")
+            self.assertEqual(Title.objects.filter(application_urls=APPHOOK).count(), 0)
+
+    def test_list_plugins(self):
+        out = StringIO()
+        apps = ["cms", "menus", "sekizai", "project.sampleapp"]
+        with SettingsOverride(INSTALLED_APPS=apps):
+            placeholder = Placeholder.objects.create(slot="test")
+            add_plugin(placeholder, TextPlugin, "en", body="en body")
+            self.assertEqual(CMSPlugin.objects.filter(plugin_type=PLUGIN).count(), 1)            
+            command = cms.Command()
+            command.stdout = out
+            command.handle("list", "plugins", interactive=False)
+            self.assertEqual(out.getvalue(), "TextPlugin\n")
+            
+    def test_uninstall_plugins_without_plugin(self):
+        out = StringIO()
+        command = cms.Command()
+        command.stdout = out
+        command.handle("uninstall", "plugins", PLUGIN, interactive=False)
+        self.assertEqual(out.getvalue(), "no 'TextPlugin' plugins found\n")
+
+    def test_uninstall_plugins_with_plugin(self):
+        out = StringIO()
+        apps = ["cms", "menus", "sekizai", "project.sampleapp"]
+        with SettingsOverride(INSTALLED_APPS=apps):
+            placeholder = Placeholder.objects.create(slot="test")
+            add_plugin(placeholder, TextPlugin, "en", body="en body")
+            self.assertEqual(CMSPlugin.objects.filter(plugin_type=PLUGIN).count(), 1)
+            command = cms.Command()
+            command.stdout = out
+            command.handle("uninstall", "plugins", PLUGIN, interactive=False)
+            self.assertEqual(out.getvalue(), "1 'TextPlugin' plugins uninstalled\n")
+            self.assertEqual(CMSPlugin.objects.filter(plugin_type=PLUGIN).count(), 0)