Repository: django-extensions/django-extensions

Issue:
dumpscript (and possibly others) are broken with Django 1.3
Django's [r14507](http://code.djangoproject.com/changeset/14507) refactored the model deletion code, breaking `dumpscript`.

The problem manifests itself by complaining that models no longer have a `_collect_sub_objects` method, since it has been [removed from django/db/models.base.py](http://code.djangoproject.com/changeset?new=django%2Ftrunk%2Fdjango%2Fdb%2Fmodels%2Fbase.py%4014507&old=django%2Ftrunk%2Fdjango%2Fdb%2Fmodels%2Fbase.py%4014045), although it should actually fail earlier: [this backwards compatibility handling](https://github.com/django-extensions/django-extensions/blob/master/django_extensions/management/commands/dumpscript.py#L231) masks the fact that `CollectedObjects` has gone away as well.

Here is the full traceback:

```
$ python manage.py dumpscript easy_pages > scripts/easy_pages.py
Processing model: Page
Traceback (most recent call last):
  File "manage.py", line 11, in <module>
    execute_manager(settings)
  File "/home/gabriel/Documents/django/trunk/django/core/management/__init__.py", line 438, in execute_manager
    utility.execute()
  File "/home/gabriel/Documents/django/trunk/django/core/management/__init__.py", line 379, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/home/gabriel/Documents/django/trunk/django/core/management/base.py", line 191, in run_from_argv
    self.execute(*args, **options.__dict__)
  File "/home/gabriel/Documents/django/trunk/django/core/management/base.py", line 220, in execute
    output = self.handle(*args, **options)
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 54, in handle
    print Script(models=models, context=context)
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 110, in __str__
    return flatten_blocks(self.lines, num_indents=self.indent)
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 336, in get_lines
    code.append(model_class.lines)
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 148, in get_lines
    code += instance.lines
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 194, in get_lines
    if self.skip():
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 238, in skip
    self.instance._collect_sub_objects(sub_objects)
    AttributeError: 'Page' object has no attribute '_collect_sub_objects'
```

dumpscript (and possibly others) are broken with Django 1.3
Django's [r14507](http://code.djangoproject.com/changeset/14507) refactored the model deletion code, breaking `dumpscript`.

The problem manifests itself by complaining that models no longer have a `_collect_sub_objects` method, since it has been [removed from django/db/models.base.py](http://code.djangoproject.com/changeset?new=django%2Ftrunk%2Fdjango%2Fdb%2Fmodels%2Fbase.py%4014507&old=django%2Ftrunk%2Fdjango%2Fdb%2Fmodels%2Fbase.py%4014045), although it should actually fail earlier: [this backwards compatibility handling](https://github.com/django-extensions/django-extensions/blob/master/django_extensions/management/commands/dumpscript.py#L231) masks the fact that `CollectedObjects` has gone away as well.

Here is the full traceback:

```
$ python manage.py dumpscript easy_pages > scripts/easy_pages.py
Processing model: Page
Traceback (most recent call last):
  File "manage.py", line 11, in <module>
    execute_manager(settings)
  File "/home/gabriel/Documents/django/trunk/django/core/management/__init__.py", line 438, in execute_manager
    utility.execute()
  File "/home/gabriel/Documents/django/trunk/django/core/management/__init__.py", line 379, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/home/gabriel/Documents/django/trunk/django/core/management/base.py", line 191, in run_from_argv
    self.execute(*args, **options.__dict__)
  File "/home/gabriel/Documents/django/trunk/django/core/management/base.py", line 220, in execute
    output = self.handle(*args, **options)
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 54, in handle
    print Script(models=models, context=context)
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 110, in __str__
    return flatten_blocks(self.lines, num_indents=self.indent)
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 336, in get_lines
    code.append(model_class.lines)
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 148, in get_lines
    code += instance.lines
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 194, in get_lines
    if self.skip():
  File "/home/gabriel/.virtualenvs/alton_mill/lib/python2.6/site-packages/django_extensions/management/commands/dumpscript.py", line 238, in skip
    self.instance._collect_sub_objects(sub_objects)
    AttributeError: 'Page' object has no attribute '_collect_sub_objects'
```



Discussion:

Resolving patch:
diff --git a/django_extensions/management/commands/dumpscript.py b/django_extensions/management/commands/dumpscript.py
--- a/django_extensions/management/commands/dumpscript.py
+++ b/django_extensions/management/commands/dumpscript.py
@@ -30,6 +30,7 @@
 """
 
 import sys
+import django
 from django.db import models
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.management.base import BaseCommand
@@ -219,8 +220,9 @@ def get_lines(self, force=False):
 
     def skip(self):
         """ Determine whether or not this object should be skipped.
-            If this model is a parent of a single subclassed instance, skip it.
-            The subclassed instance will create this parent instance for us.
+            If this model instance is a parent of a single subclassed
+            instance, skip it. The subclassed instance will create this
+            parent instance for us.
 
             TODO: Allow the user to force its creation?
         """
@@ -228,15 +230,60 @@ def skip(self):
         if self.skip_me is not None:
             return self.skip_me
 
-        try:
-            # Django trunk since r7722 uses CollectedObjects instead of dict
-            from django.db.models.query import CollectedObjects
-            sub_objects = CollectedObjects()
-        except ImportError:
-            # previous versions don't have CollectedObjects
-            sub_objects = {}
-        self.instance._collect_sub_objects(sub_objects)
-        if reduce(lambda x, y: x+y, [self.model in so._meta.parents for so in sub_objects.keys()]) == 1:
+        def get_skip_version():
+            """ Return which version of the skip code should be run
+
+                Django's deletion code was refactored in r14507 which
+                was just two days before 1.3 alpha 1 (r14519)
+            """
+            if not hasattr(self, '_SKIP_VERSION'):
+                version = django.VERSION
+                # no, it isn't lisp. I swear.
+                self._SKIP_VERSION =  (
+                    version[0] > 1 or (  # django 2k... someday :)
+                        version[0] == 1 and (  # 1.x
+                            version[1] >= 4 or  # 1.4+
+                            version[1] == 3 and not (  # 1.3.x
+                                (version[3] == 'alpha' and version[1] == 0)
+                            )
+                        )
+                    )
+                ) and 2 or 1
+            return self._SKIP_VERSION
+
+        if get_skip_version() == 1:
+            try:
+                # Django trunk since r7722 uses CollectedObjects instead of dict
+                from django.db.models.query import CollectedObjects
+                sub_objects = CollectedObjects()
+            except ImportError:
+                # previous versions don't have CollectedObjects
+                sub_objects = {}
+            self.instance._collect_sub_objects(sub_objects)
+            sub_objects = sub_objects.keys()
+
+        elif get_skip_version() == 2:
+            from django.db.models.deletion import Collector
+            from django.db import router
+            cls = self.instance.__class__
+            using = router.db_for_write(cls, instance=self.instance)
+            collector = Collector(using=using)
+            collector.collect([self.instance])
+
+            # collector stores its instances in two places. I *think* we
+            # only need collector.data, but using the batches is needed
+            # to perfectly emulate the old behaviour
+            # TODO: check if batches are really needed. If not, remove them.
+            sub_objects = sum([list(i) for i in collector.data.values()], [])
+
+            for batch in collector.batches.values():
+                # batch.values can be sets, which must be converted to lists
+                sub_objects += sum([list(i) for i in batch.values()], [])
+
+        sub_objects_parents = [so._meta.parents for so in sub_objects]
+        if [self.model in p for p in sub_objects_parents].count(True) == 1:
+            # since this instance isn't explicitly created, it's variable name
+            # can't be referenced in the script, so record None in context dict
             pk_name = self.instance._meta.pk.name
             key = '%s_%s' % (self.model.__name__, getattr(self.instance, pk_name))
             self.context[key] = None
diff --git a/django_extensions/management/commands/passwd.py b/django_extensions/management/commands/passwd.py
--- a/django_extensions/management/commands/passwd.py
+++ b/django_extensions/management/commands/passwd.py
@@ -34,4 +34,4 @@ def handle(self, *args, **options):
         u.set_password(p1)
         u.save()
 
-        return "Password changed successfully for user", u.username
+        return "Password changed successfully for user %s\n" % u.username


Test patch:
diff --git a/django_extensions/tests/__init__.py b/django_extensions/tests/__init__.py
--- a/django_extensions/tests/__init__.py
+++ b/django_extensions/tests/__init__.py
@@ -1,4 +1,5 @@
 from django.db import models
+from django_extensions.tests.test_dumpscript import DumpScriptTests
 from django_extensions.tests.utils import UTILS_TRUNCATE_LETTERS_TESTS
 from django_extensions.tests.utils import UTILS_UUID_TESTS
 try:
diff --git a/django_extensions/tests/models.py b/django_extensions/tests/models.py
--- a/django_extensions/tests/models.py
+++ b/django_extensions/tests/models.py
@@ -16,3 +16,6 @@ def __init__(self, **kwargs):
 class Secret(models.Model):
     name = EncryptedCharField(blank=True, max_length=255)
     text = EncryptedTextField(blank=True)
+    
+class Name(models.Model):
+     name = models.CharField(max_length=50)
diff --git a/django_extensions/tests/test_dumpscript.py b/django_extensions/tests/test_dumpscript.py
new file mode 100644
--- /dev/null
+++ b/django_extensions/tests/test_dumpscript.py
@@ -0,0 +1,20 @@
+import sys
+from StringIO import StringIO
+from django.test import TestCase
+from django.core.management import call_command
+from django_extensions.tests.models import Name
+
+class DumpScriptTests(TestCase):
+    def setUp(self):
+        self.real_stdout = sys.stdout
+        sys.stdout = StringIO()
+
+    def tearDown(self):
+        sys.stdout = self.real_stdout
+
+    def test_runs(self):
+        # lame test...does it run?
+        n = Name(name='Gabriel')
+        n.save()
+        call_command('dumpscript', 'tests')
+        self.assertTrue('Gabriel' in sys.stdout.getvalue())
diff --git a/django_extensions/tests/urls.py b/django_extensions/tests/urls.py
new file mode 100644
diff --git a/run_tests.py b/run_tests.py
new file mode 100644
--- /dev/null
+++ b/run_tests.py
@@ -0,0 +1,39 @@
+
+# from http://www.travisswicegood.com/2010/01/17/django-virtualenv-pip-and-fabric/
+
+from django.conf import settings
+from django.core.management import call_command
+
+def main():
+    # Dynamically configure the Django settings with the minimum necessary to
+    # get Django running tests
+    settings.configure(
+        INSTALLED_APPS=(
+            'django.contrib.auth',
+            'django.contrib.contenttypes',
+            'django.contrib.admin',
+            'django.contrib.sessions',
+            'django_extensions',
+            'django_extensions.tests',
+        ),
+        # Django replaces this, but it still wants it. *shrugs*
+        DATABASE_ENGINE = 'django.db.backends.sqlite3',
+        DATABASES = {
+            'default': {
+                'ENGINE': 'django.db.backends.sqlite3',
+            }
+        },
+        MEDIA_ROOT = '/tmp/django_extensions_test_media/',
+        MEDIA_PATH = '/media/',
+        ROOT_URLCONF = 'django_extensions.tests.urls',
+        DEBUG = True,
+		TEMPLATE_DEBUG = True
+    )
+    
+    # Fire off the tests
+    call_command('test', 'django_extensions')
+    
+
+if __name__ == '__main__':
+    main()
+