Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Fixed #6191, #11296 -- Modified the admin deletion confirmation page …

…to use the same object collection scheme as the actual deletion. This ensures that all objects that may be deleted are actually deleted, and that cyclic display problems are avoided. Thanks to carljm for the patch.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@12598 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
commit e12b3199d0c01694ca6b09add5e0f27cadffc8ad 1 parent 126ca33
Russell Keith-Magee authored February 26, 2010
12  django/contrib/admin/actions.py
@@ -36,15 +36,7 @@ def delete_selected(modeladmin, request, queryset):
36 36
 
37 37
     # Populate deletable_objects, a data structure of all related objects that
38 38
     # will also be deleted.
39  
-
40  
-    # deletable_objects must be a list if we want to use '|unordered_list' in the template
41  
-    deletable_objects = []
42  
-    perms_needed = set()
43  
-    i = 0
44  
-    for obj in queryset:
45  
-        deletable_objects.append([mark_safe(u'%s: <a href="%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []])
46  
-        get_deleted_objects(deletable_objects[i], perms_needed, request.user, obj, opts, 1, modeladmin.admin_site, levels_to_root=2)
47  
-        i=i+1
  39
+    deletable_objects, perms_needed = get_deleted_objects(queryset, opts, request.user, modeladmin.admin_site, levels_to_root=2)
48 40
 
49 41
     # The user has already confirmed the deletion.
50 42
     # Do the deletion and return a None to display the change list view again.
@@ -66,7 +58,7 @@ def delete_selected(modeladmin, request, queryset):
66 58
     context = {
67 59
         "title": _("Are you sure?"),
68 60
         "object_name": force_unicode(opts.verbose_name),
69  
-        "deletable_objects": deletable_objects,
  61
+        "deletable_objects": [deletable_objects],
70 62
         'queryset': queryset,
71 63
         "perms_lacking": perms_needed,
72 64
         "opts": opts,
4  django/contrib/admin/options.py
@@ -1080,9 +1080,7 @@ def delete_view(self, request, object_id, extra_context=None):
1080 1080
 
1081 1081
         # Populate deleted_objects, a data structure of all related objects that
1082 1082
         # will also be deleted.
1083  
-        deleted_objects = [mark_safe(u'%s: <a href="../../%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), object_id, escape(obj))), []]
1084  
-        perms_needed = set()
1085  
-        get_deleted_objects(deleted_objects, perms_needed, request.user, obj, opts, 1, self.admin_site)
  1083
+        (deleted_objects, perms_needed) = get_deleted_objects((obj,), opts, request.user, self.admin_site)
1086 1084
 
1087 1085
         if request.POST: # The user has already confirmed the deletion.
1088 1086
             if perms_needed:
4  django/contrib/admin/templates/admin/delete_selected_confirmation.html
@@ -20,8 +20,8 @@
20 20
     </ul>
21 21
 {% else %}
22 22
     <p>{% blocktrans %}Are you sure you want to delete the selected {{ object_name }} objects? All of the following objects and their related items will be deleted:{% endblocktrans %}</p>
23  
-    {% for deleteable_object in deletable_objects %}
24  
-        <ul>{{ deleteable_object|unordered_list }}</ul>
  23
+    {% for deletable_object in deletable_objects %}
  24
+        <ul>{{ deletable_object|unordered_list }}</ul>
25 25
     {% endfor %}
26 26
     <form action="" method="post">{% csrf_token %}
27 27
     <div>
282  django/contrib/admin/util.py
@@ -7,7 +7,7 @@
7 7
 from django.utils.encoding import force_unicode, smart_unicode, smart_str
8 8
 from django.utils.translation import ungettext, ugettext as _
9 9
 from django.core.urlresolvers import reverse, NoReverseMatch
10  
-
  10
+from django.utils.datastructures import SortedDict
11 11
 
12 12
 def quote(s):
13 13
     """
@@ -57,135 +57,179 @@ def flatten_fieldsets(fieldsets):
57 57
                 field_names.append(field)
58 58
     return field_names
59 59
 
60  
-def _nest_help(obj, depth, val):
61  
-    current = obj
62  
-    for i in range(depth):
63  
-        current = current[-1]
64  
-    current.append(val)
65  
-
66  
-def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
67  
-    """
68  
-    Returns the url to the admin change view for the given app_label,
69  
-    module_name and primary key.
70  
-    """
  60
+def _format_callback(obj, user, admin_site, levels_to_root, perms_needed):
  61
+    has_admin = obj.__class__ in admin_site._registry
  62
+    opts = obj._meta
71 63
     try:
72  
-        return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
  64
+        admin_url = reverse('%s:%s_%s_change'
  65
+                            % (admin_site.name,
  66
+                               opts.app_label,
  67
+                               opts.object_name.lower()),
  68
+                            None, (quote(obj._get_pk_val()),))
73 69
     except NoReverseMatch:
74  
-        return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
  70
+        admin_url = '%s%s/%s/%s/' % ('../'*levels_to_root,
  71
+                                     opts.app_label,
  72
+                                     opts.object_name.lower(),
  73
+                                     quote(obj._get_pk_val()))
  74
+    if has_admin:
  75
+        p = '%s.%s' % (opts.app_label,
  76
+                       opts.get_delete_permission())
  77
+        if not user.has_perm(p):
  78
+            perms_needed.add(opts.verbose_name)
  79
+        # Display a link to the admin page.
  80
+        return mark_safe(u'%s: <a href="%s">%s</a>' %
  81
+                         (escape(capfirst(opts.verbose_name)),
  82
+                          admin_url,
  83
+                          escape(obj)))
  84
+    else:
  85
+        # Don't display link to edit, because it either has no
  86
+        # admin or is edited inline.
  87
+        return u'%s: %s' % (capfirst(opts.verbose_name),
  88
+                            force_unicode(obj))
75 89
 
76  
-def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
  90
+def get_deleted_objects(objs, opts, user, admin_site, levels_to_root=4):
77 91
     """
78  
-    Helper function that recursively populates deleted_objects.
  92
+    Find all objects related to ``objs`` that should also be
  93
+    deleted. ``objs`` should be an iterable of objects.
  94
+
  95
+    Returns a nested list of strings suitable for display in the
  96
+    template with the ``unordered_list`` filter.
79 97
 
80  
-    `levels_to_root` defines the number of directories (../) to reach the
81  
-    admin root path. In a change_view this is 4, in a change_list view 2.
  98
+    `levels_to_root` defines the number of directories (../) to reach
  99
+    the admin root path. In a change_view this is 4, in a change_list
  100
+    view 2.
82 101
 
83 102
     This is for backwards compatibility since the options.delete_selected
84 103
     method uses this function also from a change_list view.
85 104
     This will not be used if we can reverse the URL.
86 105
     """
87  
-    nh = _nest_help # Bind to local variable for performance
88  
-    if current_depth > 16:
89  
-        return # Avoid recursing too deep.
90  
-    opts_seen = []
91  
-    for related in opts.get_all_related_objects():
92  
-        has_admin = related.model in admin_site._registry
93  
-        if related.opts in opts_seen:
94  
-            continue
95  
-        opts_seen.append(related.opts)
96  
-        rel_opts_name = related.get_accessor_name()
97  
-        if isinstance(related.field.rel, models.OneToOneRel):
98  
-            try:
99  
-                sub_obj = getattr(obj, rel_opts_name)
100  
-            except ObjectDoesNotExist:
101  
-                pass
102  
-            else:
103  
-                if has_admin:
104  
-                    p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission())
105  
-                    if not user.has_perm(p):
106  
-                        perms_needed.add(related.opts.verbose_name)
107  
-                        # We don't care about populating deleted_objects now.
108  
-                        continue
109  
-                if not has_admin:
110  
-                    # Don't display link to edit, because it either has no
111  
-                    # admin or is edited inline.
112  
-                    nh(deleted_objects, current_depth,
113  
-                        [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
114  
-                else:
115  
-                    # Display a link to the admin page.
116  
-                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
117  
-                        (escape(capfirst(related.opts.verbose_name)),
118  
-                        get_change_view_url(related.opts.app_label,
119  
-                                            related.opts.object_name.lower(),
120  
-                                            sub_obj._get_pk_val(),
121  
-                                            admin_site,
122  
-                                            levels_to_root),
123  
-                        escape(sub_obj))), []])
124  
-                get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
  106
+    collector = NestedObjects()
  107
+    for obj in objs:
  108
+        # TODO using a private model API!
  109
+        obj._collect_sub_objects(collector)
  110
+
  111
+    # TODO This next bit is needed only because GenericRelations are
  112
+    # cascade-deleted way down in the internals in
  113
+    # DeleteQuery.delete_batch_related, instead of being found by
  114
+    # _collect_sub_objects. Refs #12593.
  115
+    from django.contrib.contenttypes import generic
  116
+    for f in obj._meta.many_to_many:
  117
+        if isinstance(f, generic.GenericRelation):
  118
+            rel_manager = f.value_from_object(obj)
  119
+            for related in rel_manager.all():
  120
+                # There's a wierdness here in the case that the
  121
+                # generic-related object also has FKs pointing to it
  122
+                # from elsewhere. DeleteQuery does not follow those
  123
+                # FKs or delete any such objects explicitly (which is
  124
+                # probably a bug). Some databases may cascade those
  125
+                # deletes themselves, and some won't. So do we report
  126
+                # those objects as to-be-deleted? No right answer; for
  127
+                # now we opt to report only on objects that Django
  128
+                # will explicitly delete, at risk that some further
  129
+                # objects will be silently deleted by a
  130
+                # referential-integrity-maintaining database.
  131
+                collector.add(related.__class__, related.pk, related,
  132
+                              obj.__class__, obj)
  133
+
  134
+    perms_needed = set()
  135
+
  136
+    to_delete = collector.nested(_format_callback,
  137
+                                 user=user,
  138
+                                 admin_site=admin_site,
  139
+                                 levels_to_root=levels_to_root,
  140
+                                 perms_needed=perms_needed)
  141
+
  142
+    return to_delete, perms_needed
  143
+
  144
+
  145
+class NestedObjects(object):
  146
+    """
  147
+    A directed acyclic graph collection that exposes the add() API
  148
+    expected by Model._collect_sub_objects and can present its data as
  149
+    a nested list of objects.
  150
+
  151
+    """
  152
+    def __init__(self):
  153
+        # Use object keys of the form (model, pk) because actual model
  154
+        # objects may not be unique
  155
+
  156
+        # maps object key to list of child keys
  157
+        self.children = SortedDict()
  158
+
  159
+        # maps object key to parent key
  160
+        self.parents = SortedDict()
  161
+
  162
+        # maps object key to actual object
  163
+        self.seen = SortedDict()
  164
+
  165
+    def add(self, model, pk, obj,
  166
+            parent_model=None, parent_obj=None, nullable=False):
  167
+        """
  168
+        Add item ``obj`` to the graph. Returns True (and does nothing)
  169
+        if the item has been seen already.
  170
+
  171
+        The ``parent_obj`` argument must already exist in the graph; if
  172
+        not, it's ignored (but ``obj`` is still added with no
  173
+        parent). In any case, Model._collect_sub_objects (for whom
  174
+        this API exists) will never pass a parent that hasn't already
  175
+        been added itself.
  176
+
  177
+        These restrictions in combination ensure the graph will remain
  178
+        acyclic (but can have multiple roots).
  179
+
  180
+        ``model``, ``pk``, and ``parent_model`` arguments are ignored
  181
+        in favor of the appropriate lookups on ``obj`` and
  182
+        ``parent_obj``; unlike CollectedObjects, we can't maintain
  183
+        independence from the knowledge that we're operating on model
  184
+        instances, and we don't want to allow for inconsistency.
  185
+
  186
+        ``nullable`` arg is ignored: it doesn't affect how the tree of
  187
+        collected objects should be nested for display.
  188
+        """
  189
+        model, pk = type(obj), obj._get_pk_val()
  190
+
  191
+        key = model, pk
  192
+
  193
+        if key in self.seen:
  194
+            return True
  195
+        self.seen.setdefault(key, obj)
  196
+
  197
+        if parent_obj is not None:
  198
+            parent_model, parent_pk = (type(parent_obj),
  199
+                                       parent_obj._get_pk_val())
  200
+            parent_key = (parent_model, parent_pk)
  201
+            if parent_key in self.seen:
  202
+                self.children.setdefault(parent_key, list()).append(key)
  203
+                self.parents.setdefault(key, parent_key)
  204
+
  205
+    def _nested(self, key, format_callback=None, **kwargs):
  206
+        obj = self.seen[key]
  207
+        if format_callback:
  208
+            ret = [format_callback(obj, **kwargs)]
125 209
         else:
126  
-            has_related_objs = False
127  
-            for sub_obj in getattr(obj, rel_opts_name).all():
128  
-                has_related_objs = True
129  
-                if not has_admin:
130  
-                    # Don't display link to edit, because it either has no
131  
-                    # admin or is edited inline.
132  
-                    nh(deleted_objects, current_depth,
133  
-                        [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
134  
-                else:
135  
-                    # Display a link to the admin page.
136  
-                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
137  
-                        (escape(capfirst(related.opts.verbose_name)),
138  
-                        get_change_view_url(related.opts.app_label,
139  
-                                            related.opts.object_name.lower(),
140  
-                                            sub_obj._get_pk_val(),
141  
-                                            admin_site,
142  
-                                            levels_to_root),
143  
-                        escape(sub_obj))), []])
144  
-                get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
145  
-            # If there were related objects, and the user doesn't have
146  
-            # permission to delete them, add the missing perm to perms_needed.
147  
-            if has_admin and has_related_objs:
148  
-                p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission())
149  
-                if not user.has_perm(p):
150  
-                    perms_needed.add(related.opts.verbose_name)
151  
-    for related in opts.get_all_related_many_to_many_objects():
152  
-        has_admin = related.model in admin_site._registry
153  
-        if related.opts in opts_seen:
154  
-            continue
155  
-        opts_seen.append(related.opts)
156  
-        rel_opts_name = related.get_accessor_name()
157  
-        has_related_objs = False
158  
-
159  
-        # related.get_accessor_name() could return None for symmetrical relationships
160  
-        if rel_opts_name:
161  
-            rel_objs = getattr(obj, rel_opts_name, None)
162  
-            if rel_objs:
163  
-                has_related_objs = True
164  
-
165  
-        if has_related_objs:
166  
-            for sub_obj in rel_objs.all():
167  
-                if not has_admin:
168  
-                    # Don't display link to edit, because it either has no
169  
-                    # admin or is edited inline.
170  
-                    nh(deleted_objects, current_depth, [_('One or more %(fieldname)s in %(name)s: %(obj)s') % \
171  
-                        {'fieldname': force_unicode(related.field.verbose_name), 'name': force_unicode(related.opts.verbose_name), 'obj': escape(sub_obj)}, []])
172  
-                else:
173  
-                    # Display a link to the admin page.
174  
-                    nh(deleted_objects, current_depth, [
175  
-                        mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \
176  
-                        (u' <a href="%s">%s</a>' % \
177  
-                            (get_change_view_url(related.opts.app_label,
178  
-                                                 related.opts.object_name.lower(),
179  
-                                                 sub_obj._get_pk_val(),
180  
-                                                 admin_site,
181  
-                                                 levels_to_root),
182  
-                            escape(sub_obj)))), []])
183  
-        # If there were related objects, and the user doesn't have
184  
-        # permission to change them, add the missing perm to perms_needed.
185  
-        if has_admin and has_related_objs:
186  
-            p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
187  
-            if not user.has_perm(p):
188  
-                perms_needed.add(related.opts.verbose_name)
  210
+            ret = [obj]
  211
+
  212
+        children = []
  213
+        for child in self.children.get(key, ()):
  214
+            children.extend(self._nested(child, format_callback, **kwargs))
  215
+        if children:
  216
+            ret.append(children)
  217
+
  218
+        return ret
  219
+
  220
+    def nested(self, format_callback=None, **kwargs):
  221
+        """
  222
+        Return the graph as a nested list.
  223
+
  224
+        Passes **kwargs back to the format_callback as kwargs.
  225
+
  226
+        """
  227
+        roots = []
  228
+        for key in self.seen.keys():
  229
+            if key not in self.parents:
  230
+                roots.extend(self._nested(key, format_callback, **kwargs))
  231
+        return roots
  232
+
189 233
 
190 234
 def model_format_dict(obj):
191 235
     """
7  django/db/models/base.py
@@ -549,7 +549,8 @@ def _collect_sub_objects(self, seen_objs, parent=None, nullable=False):
549 549
              (model_class, {pk_val: obj, pk_val: obj, ...}), ...]
550 550
         """
551 551
         pk_val = self._get_pk_val()
552  
-        if seen_objs.add(self.__class__, pk_val, self, parent, nullable):
  552
+        if seen_objs.add(self.__class__, pk_val, self,
  553
+                         type(parent), parent, nullable):
553 554
             return
554 555
 
555 556
         for related in self._meta.get_all_related_objects():
@@ -560,7 +561,7 @@ def _collect_sub_objects(self, seen_objs, parent=None, nullable=False):
560 561
                 except ObjectDoesNotExist:
561 562
                     pass
562 563
                 else:
563  
-                    sub_obj._collect_sub_objects(seen_objs, self.__class__, related.field.null)
  564
+                    sub_obj._collect_sub_objects(seen_objs, self, related.field.null)
564 565
             else:
565 566
                 # To make sure we can access all elements, we can't use the
566 567
                 # normal manager on the related object. So we work directly
@@ -578,7 +579,7 @@ def _collect_sub_objects(self, seen_objs, parent=None, nullable=False):
578 579
                         continue
579 580
                 delete_qs = rel_descriptor.delete_manager(self).all()
580 581
                 for sub_obj in delete_qs:
581  
-                    sub_obj._collect_sub_objects(seen_objs, self.__class__, related.field.null)
  582
+                    sub_obj._collect_sub_objects(seen_objs, self, related.field.null)
582 583
 
583 584
         # Handle any ancestors (for the model-inheritance case). We do this by
584 585
         # traversing to the most remote parent classes -- those with no parents
4  django/db/models/query_utils.py
@@ -50,7 +50,7 @@ def __init__(self, previously_seen=None):
50 50
         else:
51 51
             self.blocked = {}
52 52
 
53  
-    def add(self, model, pk, obj, parent_model, nullable=False):
  53
+    def add(self, model, pk, obj, parent_model, parent_obj=None, nullable=False):
54 54
         """
55 55
         Adds an item to the container.
56 56
 
@@ -60,6 +60,8 @@ def add(self, model, pk, obj, parent_model, nullable=False):
60 60
         * obj - the object itself.
61 61
         * parent_model - the model of the parent object that this object was
62 62
           reached through.
  63
+        * parent_obj - the parent object this object was reached
  64
+          through (not used here, but needed in the API for use elsewhere)
63 65
         * nullable - should be True if this relation is nullable.
64 66
 
65 67
         Returns True if the item already existed in the structure and
3  tests/regressiontests/admin_util/models.py
@@ -17,3 +17,6 @@ def test_from_model(self):
17 17
     def test_from_model_with_override(self):
18 18
         return "nothing"
19 19
     test_from_model_with_override.short_description = "not what you expect"
  20
+
  21
+class Count(models.Model):
  22
+    num = models.PositiveSmallIntegerField()
57  tests/regressiontests/admin_util/tests.py
@@ -2,15 +2,68 @@
2 2
 import unittest
3 3
 
4 4
 from django.db import models
  5
+from django.utils.formats import localize
  6
+from django.test import TestCase
5 7
 
6 8
 from django.contrib import admin
7 9
 from django.contrib.admin.util import display_for_field, label_for_field, lookup_field
8 10
 from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
9 11
 from django.contrib.sites.models import Site
10  
-from django.utils.formats import localize
  12
+from django.contrib.admin.util import NestedObjects
  13
+
  14
+from models import Article, Count
  15
+
  16
+
  17
+class NestedObjectsTests(TestCase):
  18
+    """
  19
+    Tests for ``NestedObject`` utility collection.
  20
+
  21
+    """
  22
+    def setUp(self):
  23
+        self.n = NestedObjects()
  24
+        self.objs = [Count.objects.create(num=i) for i in range(5)]
  25
+
  26
+    def _check(self, target):
  27
+        self.assertEquals(self.n.nested(lambda obj: obj.num), target)
  28
+
  29
+    def _add(self, obj, parent=None):
  30
+        # don't bother providing the extra args that NestedObjects ignores
  31
+        self.n.add(None, None, obj, None, parent)
  32
+
  33
+    def test_unrelated_roots(self):
  34
+        self._add(self.objs[0])
  35
+        self._add(self.objs[1])
  36
+        self._add(self.objs[2], self.objs[1])
  37
+
  38
+        self._check([0, 1, [2]])
  39
+
  40
+    def test_siblings(self):
  41
+        self._add(self.objs[0])
  42
+        self._add(self.objs[1], self.objs[0])
  43
+        self._add(self.objs[2], self.objs[0])
  44
+
  45
+        self._check([0, [1, 2]])
  46
+
  47
+    def test_duplicate_instances(self):
  48
+        self._add(self.objs[0])
  49
+        self._add(self.objs[1])
  50
+        dupe = Count.objects.get(num=1)
  51
+        self._add(dupe, self.objs[0])
  52
+
  53
+        self._check([0, 1])
  54
+
  55
+    def test_non_added_parent(self):
  56
+        self._add(self.objs[0], self.objs[1])
  57
+
  58
+        self._check([0])
11 59
 
12  
-from models import Article
  60
+    def test_cyclic(self):
  61
+        self._add(self.objs[0], self.objs[2])
  62
+        self._add(self.objs[1], self.objs[0])
  63
+        self._add(self.objs[2], self.objs[1])
  64
+        self._add(self.objs[0], self.objs[2])
13 65
 
  66
+        self._check([0, [1, [2]]])
14 67
 
15 68
 
16 69
 class UtilTests(unittest.TestCase):
74  tests/regressiontests/admin_views/models.py
@@ -10,7 +10,8 @@
10 10
 from django.db import models
11 11
 from django import forms
12 12
 from django.forms.models import BaseModelFormSet
13  
-
  13
+from django.contrib.contenttypes import generic
  14
+from django.contrib.contenttypes.models import ContentType
14 15
 
15 16
 class Section(models.Model):
16 17
     """
@@ -494,6 +495,71 @@ class GadgetAdmin(admin.ModelAdmin):
494 495
     def get_changelist(self, request, **kwargs):
495 496
         return CustomChangeList
496 497
 
  498
+class Villain(models.Model):
  499
+    name = models.CharField(max_length=100)
  500
+
  501
+    def __unicode__(self):
  502
+        return self.name
  503
+
  504
+class SuperVillain(Villain):
  505
+    pass
  506
+
  507
+class FunkyTag(models.Model):
  508
+    "Because we all know there's only one real use case for GFKs."
  509
+    name = models.CharField(max_length=25)
  510
+    content_type = models.ForeignKey(ContentType)
  511
+    object_id = models.PositiveIntegerField()
  512
+    content_object = generic.GenericForeignKey('content_type', 'object_id')
  513
+
  514
+    def __unicode__(self):
  515
+        return self.name
  516
+
  517
+class Plot(models.Model):
  518
+    name = models.CharField(max_length=100)
  519
+    team_leader = models.ForeignKey(Villain, related_name='lead_plots')
  520
+    contact = models.ForeignKey(Villain, related_name='contact_plots')
  521
+    tags = generic.GenericRelation(FunkyTag)
  522
+
  523
+    def __unicode__(self):
  524
+        return self.name
  525
+
  526
+class PlotDetails(models.Model):
  527
+    details = models.CharField(max_length=100)
  528
+    plot = models.OneToOneField(Plot)
  529
+
  530
+    def __unicode__(self):
  531
+        return self.details
  532
+
  533
+class SecretHideout(models.Model):
  534
+    """ Secret! Not registered with the admin! """
  535
+    location = models.CharField(max_length=100)
  536
+    villain = models.ForeignKey(Villain)
  537
+
  538
+    def __unicode__(self):
  539
+        return self.location
  540
+
  541
+class SuperSecretHideout(models.Model):
  542
+    """ Secret! Not registered with the admin! """
  543
+    location = models.CharField(max_length=100)
  544
+    supervillain = models.ForeignKey(SuperVillain)
  545
+
  546
+    def __unicode__(self):
  547
+        return self.location
  548
+
  549
+class CyclicOne(models.Model):
  550
+    name = models.CharField(max_length=25)
  551
+    two = models.ForeignKey('CyclicTwo')
  552
+
  553
+    def __unicode__(self):
  554
+        return self.name
  555
+
  556
+class CyclicTwo(models.Model):
  557
+    name = models.CharField(max_length=25)
  558
+    one = models.ForeignKey(CyclicOne)
  559
+
  560
+    def __unicode__(self):
  561
+        return self.name
  562
+
497 563
 admin.site.register(Article, ArticleAdmin)
498 564
 admin.site.register(CustomArticle, CustomArticleAdmin)
499 565
 admin.site.register(Section, save_as=True, inlines=[ArticleInline])
@@ -519,6 +585,12 @@ def get_changelist(self, request, **kwargs):
519 585
 admin.site.register(Category, CategoryAdmin)
520 586
 admin.site.register(Post, PostAdmin)
521 587
 admin.site.register(Gadget, GadgetAdmin)
  588
+admin.site.register(Villain)
  589
+admin.site.register(SuperVillain)
  590
+admin.site.register(Plot)
  591
+admin.site.register(PlotDetails)
  592
+admin.site.register(CyclicOne)
  593
+admin.site.register(CyclicTwo)
522 594
 
523 595
 # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
524 596
 # That way we cover all four cases:
113  tests/regressiontests/admin_views/tests.py
@@ -16,13 +16,14 @@
16 16
 from django.utils.cache import get_max_age
17 17
 from django.utils.html import escape
18 18
 from django.utils.translation import get_date_formats
  19
+from django.utils.encoding import iri_to_uri
19 20
 
20 21
 # local test models
21 22
 from models import Article, BarAccount, CustomArticle, EmptyModel, \
22 23
     ExternalSubscriber, FooAccount, Gallery, ModelWithStringPrimaryKey, \
23 24
     Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
24 25
     Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
25  
-    Category, Post
  26
+    Category, Post, Plot, FunkyTag
26 27
 
27 28
 
28 29
 class AdminViewBasicTest(TestCase):
@@ -637,6 +638,113 @@ def testDisabledPermissionsWhenLoggedIn(self):
637 638
         response = self.client.get('/test_admin/admin/secure-view/')
638 639
         self.assertContains(response, 'id="login-form"')
639 640
 
  641
+
  642
+class AdminViewDeletedObjectsTest(TestCase):
  643
+    fixtures = ['admin-views-users.xml', 'deleted-objects.xml']
  644
+
  645
+    def setUp(self):
  646
+        self.client.login(username='super', password='secret')
  647
+
  648
+    def tearDown(self):
  649
+        self.client.logout()
  650
+
  651
+    def test_nesting(self):
  652
+        """
  653
+        Objects should be nested to display the relationships that
  654
+        cause them to be scheduled for deletion.
  655
+        """
  656
+        pattern = re.compile(r"""<li>Plot: <a href=".+/admin_views/plot/1/">World Domination</a>\s*<ul>\s*<li>Plot details: <a href=".+/admin_views/plotdetails/1/">almost finished</a>""")
  657
+        response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
  658
+        self.failUnless(pattern.search(response.content))
  659
+
  660
+    def test_cyclic(self):
  661
+        """
  662
+        Cyclic relationships should still cause each object to only be
  663
+        listed once.
  664
+
  665
+        """
  666
+        one = """<li>Cyclic one: <a href="/test_admin/admin/admin_views/cyclicone/1/">I am recursive</a>"""
  667
+        two = """<li>Cyclic two: <a href="/test_admin/admin/admin_views/cyclictwo/1/">I am recursive too</a>"""
  668
+        response = self.client.get('/test_admin/admin/admin_views/cyclicone/%s/delete/' % quote(1))
  669
+
  670
+        self.assertContains(response, one, 1)
  671
+        self.assertContains(response, two, 1)
  672
+
  673
+    def test_perms_needed(self):
  674
+        self.client.logout()
  675
+        delete_user = User.objects.get(username='deleteuser')
  676
+        delete_user.user_permissions.add(get_perm(Plot,
  677
+            Plot._meta.get_delete_permission()))
  678
+
  679
+        self.failUnless(self.client.login(username='deleteuser',
  680
+                                          password='secret'))
  681
+
  682
+        response = self.client.get('/test_admin/admin/admin_views/plot/%s/delete/' % quote(1))
  683
+        self.assertContains(response, "your account doesn't have permission to delete the following types of objects")
  684
+        self.assertContains(response, "<li>plot details</li>")
  685
+
  686
+
  687
+    def test_not_registered(self):
  688
+        should_contain = """<li>Secret hideout: underground bunker"""
  689
+        response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
  690
+        self.assertContains(response, should_contain, 1)
  691
+
  692
+    def test_multiple_fkeys_to_same_model(self):
  693
+        """
  694
+        If a deleted object has two relationships from another model,
  695
+        both of those should be followed in looking for related
  696
+        objects to delete.
  697
+
  698
+        """
  699
+        should_contain = """<li>Plot: <a href="/test_admin/admin/admin_views/plot/1/">World Domination</a>"""
  700
+        response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
  701
+        self.assertContains(response, should_contain)
  702
+        response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(2))
  703
+        self.assertContains(response, should_contain)
  704
+
  705
+    def test_multiple_fkeys_to_same_instance(self):
  706
+        """
  707
+        If a deleted object has two relationships pointing to it from
  708
+        another object, the other object should still only be listed
  709
+        once.
  710
+
  711
+        """
  712
+        should_contain = """<li>Plot: <a href="/test_admin/admin/admin_views/plot/2/">World Peace</a></li>"""
  713
+        response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(2))
  714
+        self.assertContains(response, should_contain, 1)
  715
+
  716
+    def test_inheritance(self):
  717
+        """
  718
+        In the case of an inherited model, if either the child or
  719
+        parent-model instance is deleted, both instances are listed
  720
+        for deletion, as well as any relationships they have.
  721
+
  722
+        """
  723
+        should_contain = [
  724
+            """<li>Villain: <a href="/test_admin/admin/admin_views/villain/3/">Bob</a>""",
  725
+            """<li>Super villain: <a href="/test_admin/admin/admin_views/supervillain/3/">Bob</a>""",
  726
+            """<li>Secret hideout: floating castle""",
  727
+            """<li>Super secret hideout: super floating castle!"""
  728
+            ]
  729
+        response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(3))
  730
+        for should in should_contain:
  731
+            self.assertContains(response, should, 1)
  732
+        response = self.client.get('/test_admin/admin/admin_views/supervillain/%s/delete/' % quote(3))
  733
+        for should in should_contain:
  734
+            self.assertContains(response, should, 1)
  735
+
  736
+    def test_generic_relations(self):
  737
+        """
  738
+        If a deleted object has GenericForeignKeys pointing to it,
  739
+        those objects should be listed for deletion.
  740
+
  741
+        """
  742
+        plot = Plot.objects.get(pk=3)
  743
+        tag = FunkyTag.objects.create(content_object=plot, name='hott')
  744
+        should_contain = """<li>Funky tag: hott"""
  745
+        response = self.client.get('/test_admin/admin/admin_views/plot/%s/delete/' % quote(3))
  746
+        self.assertContains(response, should_contain)
  747
+
640 748
 class AdminViewStringPrimaryKeyTest(TestCase):
641 749
     fixtures = ['admin-views-users.xml', 'string-primary-key.xml']
642 750
 
@@ -699,7 +807,8 @@ def test_recentactions_without_content_type(self):
699 807
     def test_deleteconfirmation_link(self):
700 808
         "The link from the delete confirmation page referring back to the changeform of the object should be quoted"
701 809
         response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/%s/delete/' % quote(self.pk))
702  
-        should_contain = """<a href="../../%s/">%s</a>""" % (quote(self.pk), escape(self.pk))
  810
+        # this URL now comes through reverse(), thus iri_to_uri encoding
  811
+        should_contain = """/%s/">%s</a>""" % (iri_to_uri(quote(self.pk)), escape(self.pk))
703 812
         self.assertContains(response, should_contain)
704 813
 
705 814
     def test_url_conflicts_with_add(self):

0 notes on commit e12b319

Please sign in to comment.
Something went wrong with that request. Please try again.