Repository: django-cms/django-filer

Issue:
500 error delete view with mixed objects
If the delete view has polymorphic results, django falls over polymorphic.

to reproduce:
add images and files to a folder and try to delete the folder



Discussion:

Resolving patch:
diff --git a/filer/admin/folderadmin.py b/filer/admin/folderadmin.py
--- a/filer/admin/folderadmin.py
+++ b/filer/admin/folderadmin.py
@@ -38,7 +38,6 @@
 from ..settings import FILER_PAGINATE_BY
 from ..thumbnail_processors import normalize_subject_location
 from ..utils.compatibility import (
-    LTE_DJANGO_1_6,
     capfirst,
     get_delete_permission,
     quote,
@@ -171,6 +170,14 @@ def delete_view(self, request, object_id, extra_context=None):
         we need to fetch the object and find out who the parent is
         before super, because super will delete the object and make it
         impossible to find out the parent folder to redirect to.
+
+        The delete_view breaks with polymorphic models if the cascade will
+        try delete objects that are of different polymorphic types
+        (AttributeError: 'File' object has no attribute 'file_ptr').
+        The default implementation of the delete_view is hard to override
+        without just copying the whole big thing. Since we've already done
+        the overriding work on the delete_files_or_folders admin action, we
+        can re-use that here instead.
         """
         try:
             obj = self.get_queryset(request).get(pk=unquote(object_id))
@@ -178,17 +185,12 @@ def delete_view(self, request, object_id, extra_context=None):
         except self.model.DoesNotExist:
             parent_folder = None
 
-        admin_context = AdminContext(request)
-        if LTE_DJANGO_1_6:
-            extra_context = extra_context or {}
-            extra_context.update({'is_popup': admin_context.popup})
         if request.POST:
-            # Popup in pick mode. Call super delete view so the objects
-            # actually get deleted. All possible failures in delete_view cause
-            # exceptions, so it is safe to ignore the return value though.
-            super(FolderAdmin, self).delete_view(
-                request=request, object_id=object_id,
-                extra_context=extra_context)
+            self.delete_files_or_folders(
+                request,
+                files_queryset=File.objects.none(),
+                folders_queryset=Folder.objects.filter(id=object_id)
+            )
             if parent_folder:
                 url = reverse('admin:filer-directory_listing',
                               kwargs={'folder_id': parent_folder.id})
@@ -199,9 +201,12 @@ def delete_view(self, request, object_id, extra_context=None):
                 admin_url_params_encoded(request),
             )
             return HttpResponseRedirect(url)
-        return super(FolderAdmin, self).delete_view(
-            request=request, object_id=object_id,
-            extra_context=extra_context)
+
+        return self.delete_files_or_folders(
+            request,
+            files_queryset=File.objects.none(),
+            folders_queryset=Folder.objects.filter(id=object_id)
+        )
 
     def icon_img(self, xs):
         return mark_safe(('<img src="%simg/icons/plainfolder_32x32.png" '
@@ -711,8 +716,8 @@ def delete_files_or_folders(self, request, files_queryset, folders_queryset):
         # permissions.
         # TODO: Check if permissions are really verified
         using = router.db_for_write(self.model)
-        deletable_files, perms_needed_files, protected_files = get_deleted_objects(files_queryset, files_queryset.model._meta, request.user, self.admin_site, using)
-        deletable_folders, perms_needed_folders, protected_folders = get_deleted_objects(folders_queryset, folders_queryset.model._meta, request.user, self.admin_site, using)
+        deletable_files, model_count_files, perms_needed_files, protected_files = get_deleted_objects(files_queryset, files_queryset.model._meta, request.user, self.admin_site, using)
+        deletable_folders, model_count_folder, perms_needed_folders, protected_folders = get_deleted_objects(folders_queryset, folders_queryset.model._meta, request.user, self.admin_site, using)
         all_protected.extend(protected_files)
         all_protected.extend(protected_folders)
 
diff --git a/filer/admin/patched/admin_utils.py b/filer/admin/patched/admin_utils.py
--- a/filer/admin/patched/admin_utils.py
+++ b/filer/admin/patched/admin_utils.py
@@ -1,21 +1,28 @@
 # -*- coding: utf-8 -*-
 """
 Copy of ``django.contrib.admin.utils.get_deleted_objects`` and a subclass of
-``django.contrib.admin.utils.NestedObjects`` that work with djongo_polymorphic querysets.
-Ultimatly these should go directly into django_polymorphic or, in a more generic way, into django itself.
+``django.contrib.admin.utils.NestedObjects`` that work with djongo_polymorphic
+querysets.
+Ultimatly these should go directly into django_polymorphic or, in a more
+generic way, into django itself.
 
-This code has been copied from Django 1.4.
+This code has been copied from Django 1.9.4.
 
-At all locations where something has been changed, there are inline comments in the code.
+At all locations where something has been changed, there are inline comments
+in the code.
 """
 from __future__ import absolute_import, unicode_literals
 
-from django.core.urlresolvers import reverse
-from django.utils.html import escape
-from django.utils.safestring import mark_safe
+from collections import defaultdict
+
+from django.contrib.auth import get_permission_codename
+from django.core.urlresolvers import NoReverseMatch, reverse
+from django.db import models
+from django.db.models.deletion import Collector
+from django.utils.html import format_html
 from django.utils.text import capfirst
 
-from ...utils.compatibility import NestedObjects, get_delete_permission, quote
+from ...utils.compatibility import quote
 
 try:
     from django.utils.encoding import force_text
@@ -27,11 +34,9 @@
 def get_deleted_objects(objs, opts, user, admin_site, using):
     """
     Find all objects related to ``objs`` that should also be deleted. ``objs``
-    must be a homogenous iterable of objects (e.g. a QuerySet).
-
+    must be a homogeneous iterable of objects (e.g. a QuerySet).
     Returns a nested list of strings suitable for display in the
     template with the ``unordered_list`` filter.
-
     """
     # --- begin patch ---
     collector = PolymorphicAwareNestedObjects(using=using)
@@ -43,31 +48,103 @@ def format_callback(obj):
         has_admin = obj.__class__ in admin_site._registry
         opts = obj._meta
 
+        no_edit_link = '%s: %s' % (capfirst(opts.verbose_name),
+                                   force_text(obj))
+
         if has_admin:
-            admin_url = reverse('%s:%s_%s_change'
-            % (admin_site.name,
-               opts.app_label,
-               opts.object_name.lower()),
-                None, (quote(obj._get_pk_val()),))
-            p = get_delete_permission(opts)
+            try:
+                admin_url = reverse('%s:%s_%s_change'
+                                    % (admin_site.name,
+                                       opts.app_label,
+                                       opts.model_name),
+                                    None, (quote(obj._get_pk_val()),))
+            except NoReverseMatch:
+                # Change url doesn't exist -- don't display link to edit
+                return no_edit_link
+
+            p = '%s.%s' % (opts.app_label,
+                           get_permission_codename('delete', opts))
             if not user.has_perm(p):
                 perms_needed.add(opts.verbose_name)
-                # Display a link to the admin page.
-            return mark_safe('%s: <a href="%s">%s</a>' %
-                             (escape(capfirst(opts.verbose_name)),
-                              admin_url,
-                              escape(obj)))
+            # Display a link to the admin page.
+            return format_html('{}: <a href="{}">{}</a>',
+                               capfirst(opts.verbose_name),
+                               admin_url,
+                               obj)
         else:
             # Don't display link to edit, because it either has no
             # admin or is edited inline.
-            return '%s: %s' % (capfirst(opts.verbose_name),
-                               force_text(obj))
+            return no_edit_link
 
     to_delete = collector.nested(format_callback)
 
     protected = [format_callback(obj) for obj in collector.protected]
-
-    return to_delete, perms_needed, protected
+    model_count = {model._meta.verbose_name_plural: len(objs) for model, objs in collector.model_objs.items()}
+
+    return to_delete, model_count, perms_needed, protected
+
+
+class NestedObjects(Collector):
+    def __init__(self, *args, **kwargs):
+        super(NestedObjects, self).__init__(*args, **kwargs)
+        self.edges = {}  # {from_instance: [to_instances]}
+        self.protected = set()
+        self.model_objs = defaultdict(set)
+
+    def add_edge(self, source, target):
+        self.edges.setdefault(source, []).append(target)
+
+    def collect(self, objs, source=None, source_attr=None, **kwargs):
+        for obj in objs:
+            if source_attr and not source_attr.endswith('+'):
+                related_name = source_attr % {
+                    'class': source._meta.model_name,
+                    'app_label': source._meta.app_label,
+                }
+                self.add_edge(getattr(obj, related_name), obj)
+            else:
+                self.add_edge(None, obj)
+            self.model_objs[obj._meta.model].add(obj)
+        try:
+            return super(NestedObjects, self).collect(objs, source_attr=source_attr, **kwargs)
+        except models.ProtectedError as e:
+            self.protected.update(e.protected_objects)
+
+    def related_objects(self, related, objs):
+        qs = super(NestedObjects, self).related_objects(related, objs)
+        return qs.select_related(related.field.name)
+
+    def _nested(self, obj, seen, format_callback):
+        if obj in seen:
+            return []
+        seen.add(obj)
+        children = []
+        for child in self.edges.get(obj, ()):
+            children.extend(self._nested(child, seen, format_callback))
+        if format_callback:
+            ret = [format_callback(obj)]
+        else:
+            ret = [obj]
+        if children:
+            ret.append(children)
+        return ret
+
+    def nested(self, format_callback=None):
+        """
+        Return the graph as a nested list.
+        """
+        seen = set()
+        roots = []
+        for root in self.edges.get(None, ()):
+            roots.extend(self._nested(root, seen, format_callback))
+        return roots
+
+    def can_fast_delete(self, *args, **kwargs):
+        """
+        We always want to load the objects into memory so that we can display
+        them to the user in confirm page.
+        """
+        return False
 
 
 class PolymorphicAwareNestedObjects(NestedObjects):


Test patch:
diff --git a/filer/tests/admin.py b/filer/tests/admin.py
--- a/filer/tests/admin.py
+++ b/filer/tests/admin.py
@@ -545,6 +545,13 @@ def test_delete_files_or_folders_action(self):
         folders = []
         for folder in FolderRoot().children.all():
             folders.append('folder-%d' % (folder.id,))
+        # this returns the confirmation for the admin action
+        response = self.client.post(url, {
+            'action': 'delete_files_or_folders',
+            'post': 'no',
+            helpers.ACTION_CHECKBOX_NAME: folders,
+        })
+        # this does the actual deleting
         response = self.client.post(url, {
             'action': 'delete_files_or_folders',
             'post': 'yes',
@@ -558,6 +565,7 @@ def test_delete_files_or_folders_action_with_mixed_types(self):
         self.create_file(folder=self.src_folder)
         self.create_image(folder=self.src_folder)
         self.create_file(folder=self.src_folder)
+        self.create_image(folder=self.src_folder)
 
         self.assertNotEqual(File.objects.count(), 0)
         self.assertNotEqual(Image.objects.count(), 0)
@@ -1031,3 +1039,33 @@ def test_regular_mode_folder_with_parent_save(self):
                 args=[parent_folder.id]
             )
         )
+
+
+class PolymorphicDeleteViewTests(BulkOperationsMixin, TestCase):
+    def test_can_delete_mixed_file_and_image_items(self):
+        """
+        we need to use a patched version of the get_deleted_objects so it works
+        with polymorphic models.
+        see filer.admin.patched.admin_utils.get_deleted_objects
+        """
+        folder = Folder.objects.create(name='a folder with files and images inside')
+        self.create_image(folder=folder, filename="i-am-a-image.jpg")
+        self.create_file(folder=folder, filename="i-am-a-file.bin")
+        self.assertEqual(Folder.objects.filter(id=folder.id).count(), 1)
+
+        response = self.client.get(folder.get_admin_delete_url())
+        self.assertEquals(response.status_code, 200)
+
+        response = self.client.post(
+            folder.get_admin_delete_url(),
+            {
+                'post': 'yes',
+            }
+        )
+        self.assertRedirects(
+            response=response,
+            expected_url=reverse(
+                'admin:filer-directory_listing-root'
+            )
+        )
+        self.assertEqual(Folder.objects.filter(id=folder.id).count(), 0)