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
This commit is contained in:
parent
126ca330e2
commit
e12b3199d0
|
@ -36,15 +36,7 @@ def delete_selected(modeladmin, request, queryset):
|
|||
|
||||
# Populate deletable_objects, a data structure of all related objects that
|
||||
# will also be deleted.
|
||||
|
||||
# deletable_objects must be a list if we want to use '|unordered_list' in the template
|
||||
deletable_objects = []
|
||||
perms_needed = set()
|
||||
i = 0
|
||||
for obj in queryset:
|
||||
deletable_objects.append([mark_safe(u'%s: <a href="%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []])
|
||||
get_deleted_objects(deletable_objects[i], perms_needed, request.user, obj, opts, 1, modeladmin.admin_site, levels_to_root=2)
|
||||
i=i+1
|
||||
deletable_objects, perms_needed = get_deleted_objects(queryset, opts, request.user, modeladmin.admin_site, levels_to_root=2)
|
||||
|
||||
# The user has already confirmed the deletion.
|
||||
# Do the deletion and return a None to display the change list view again.
|
||||
|
@ -66,7 +58,7 @@ def delete_selected(modeladmin, request, queryset):
|
|||
context = {
|
||||
"title": _("Are you sure?"),
|
||||
"object_name": force_unicode(opts.verbose_name),
|
||||
"deletable_objects": deletable_objects,
|
||||
"deletable_objects": [deletable_objects],
|
||||
'queryset': queryset,
|
||||
"perms_lacking": perms_needed,
|
||||
"opts": opts,
|
||||
|
|
|
@ -1080,9 +1080,7 @@ class ModelAdmin(BaseModelAdmin):
|
|||
|
||||
# Populate deleted_objects, a data structure of all related objects that
|
||||
# will also be deleted.
|
||||
deleted_objects = [mark_safe(u'%s: <a href="../../%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), object_id, escape(obj))), []]
|
||||
perms_needed = set()
|
||||
get_deleted_objects(deleted_objects, perms_needed, request.user, obj, opts, 1, self.admin_site)
|
||||
(deleted_objects, perms_needed) = get_deleted_objects((obj,), opts, request.user, self.admin_site)
|
||||
|
||||
if request.POST: # The user has already confirmed the deletion.
|
||||
if perms_needed:
|
||||
|
|
|
@ -20,8 +20,8 @@
|
|||
</ul>
|
||||
{% else %}
|
||||
<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>
|
||||
{% for deleteable_object in deletable_objects %}
|
||||
<ul>{{ deleteable_object|unordered_list }}</ul>
|
||||
{% for deletable_object in deletable_objects %}
|
||||
<ul>{{ deletable_object|unordered_list }}</ul>
|
||||
{% endfor %}
|
||||
<form action="" method="post">{% csrf_token %}
|
||||
<div>
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.utils.text import capfirst
|
|||
from django.utils.encoding import force_unicode, smart_unicode, smart_str
|
||||
from django.utils.translation import ungettext, ugettext as _
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
|
||||
from django.utils.datastructures import SortedDict
|
||||
|
||||
def quote(s):
|
||||
"""
|
||||
|
@ -57,135 +57,179 @@ def flatten_fieldsets(fieldsets):
|
|||
field_names.append(field)
|
||||
return field_names
|
||||
|
||||
def _nest_help(obj, depth, val):
|
||||
current = obj
|
||||
for i in range(depth):
|
||||
current = current[-1]
|
||||
current.append(val)
|
||||
|
||||
def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
|
||||
"""
|
||||
Returns the url to the admin change view for the given app_label,
|
||||
module_name and primary key.
|
||||
"""
|
||||
def _format_callback(obj, user, admin_site, levels_to_root, perms_needed):
|
||||
has_admin = obj.__class__ in admin_site._registry
|
||||
opts = obj._meta
|
||||
try:
|
||||
return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
|
||||
admin_url = reverse('%s:%s_%s_change'
|
||||
% (admin_site.name,
|
||||
opts.app_label,
|
||||
opts.object_name.lower()),
|
||||
None, (quote(obj._get_pk_val()),))
|
||||
except NoReverseMatch:
|
||||
return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
|
||||
admin_url = '%s%s/%s/%s/' % ('../'*levels_to_root,
|
||||
opts.app_label,
|
||||
opts.object_name.lower(),
|
||||
quote(obj._get_pk_val()))
|
||||
if has_admin:
|
||||
p = '%s.%s' % (opts.app_label,
|
||||
opts.get_delete_permission())
|
||||
if not user.has_perm(p):
|
||||
perms_needed.add(opts.verbose_name)
|
||||
# Display a link to the admin page.
|
||||
return mark_safe(u'%s: <a href="%s">%s</a>' %
|
||||
(escape(capfirst(opts.verbose_name)),
|
||||
admin_url,
|
||||
escape(obj)))
|
||||
else:
|
||||
# Don't display link to edit, because it either has no
|
||||
# admin or is edited inline.
|
||||
return u'%s: %s' % (capfirst(opts.verbose_name),
|
||||
force_unicode(obj))
|
||||
|
||||
def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
|
||||
def get_deleted_objects(objs, opts, user, admin_site, levels_to_root=4):
|
||||
"""
|
||||
Helper function that recursively populates deleted_objects.
|
||||
Find all objects related to ``objs`` that should also be
|
||||
deleted. ``objs`` should be an iterable of objects.
|
||||
|
||||
`levels_to_root` defines the number of directories (../) to reach the
|
||||
admin root path. In a change_view this is 4, in a change_list view 2.
|
||||
Returns a nested list of strings suitable for display in the
|
||||
template with the ``unordered_list`` filter.
|
||||
|
||||
`levels_to_root` defines the number of directories (../) to reach
|
||||
the admin root path. In a change_view this is 4, in a change_list
|
||||
view 2.
|
||||
|
||||
This is for backwards compatibility since the options.delete_selected
|
||||
method uses this function also from a change_list view.
|
||||
This will not be used if we can reverse the URL.
|
||||
"""
|
||||
nh = _nest_help # Bind to local variable for performance
|
||||
if current_depth > 16:
|
||||
return # Avoid recursing too deep.
|
||||
opts_seen = []
|
||||
for related in opts.get_all_related_objects():
|
||||
has_admin = related.model in admin_site._registry
|
||||
if related.opts in opts_seen:
|
||||
continue
|
||||
opts_seen.append(related.opts)
|
||||
rel_opts_name = related.get_accessor_name()
|
||||
if isinstance(related.field.rel, models.OneToOneRel):
|
||||
try:
|
||||
sub_obj = getattr(obj, rel_opts_name)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
else:
|
||||
if has_admin:
|
||||
p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission())
|
||||
if not user.has_perm(p):
|
||||
perms_needed.add(related.opts.verbose_name)
|
||||
# We don't care about populating deleted_objects now.
|
||||
continue
|
||||
if not has_admin:
|
||||
# Don't display link to edit, because it either has no
|
||||
# admin or is edited inline.
|
||||
nh(deleted_objects, current_depth,
|
||||
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
|
||||
else:
|
||||
# Display a link to the admin page.
|
||||
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
|
||||
(escape(capfirst(related.opts.verbose_name)),
|
||||
get_change_view_url(related.opts.app_label,
|
||||
related.opts.object_name.lower(),
|
||||
sub_obj._get_pk_val(),
|
||||
admin_site,
|
||||
levels_to_root),
|
||||
escape(sub_obj))), []])
|
||||
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
|
||||
else:
|
||||
has_related_objs = False
|
||||
for sub_obj in getattr(obj, rel_opts_name).all():
|
||||
has_related_objs = True
|
||||
if not has_admin:
|
||||
# Don't display link to edit, because it either has no
|
||||
# admin or is edited inline.
|
||||
nh(deleted_objects, current_depth,
|
||||
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
|
||||
else:
|
||||
# Display a link to the admin page.
|
||||
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
|
||||
(escape(capfirst(related.opts.verbose_name)),
|
||||
get_change_view_url(related.opts.app_label,
|
||||
related.opts.object_name.lower(),
|
||||
sub_obj._get_pk_val(),
|
||||
admin_site,
|
||||
levels_to_root),
|
||||
escape(sub_obj))), []])
|
||||
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
|
||||
# If there were related objects, and the user doesn't have
|
||||
# permission to delete them, add the missing perm to perms_needed.
|
||||
if has_admin and has_related_objs:
|
||||
p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission())
|
||||
if not user.has_perm(p):
|
||||
perms_needed.add(related.opts.verbose_name)
|
||||
for related in opts.get_all_related_many_to_many_objects():
|
||||
has_admin = related.model in admin_site._registry
|
||||
if related.opts in opts_seen:
|
||||
continue
|
||||
opts_seen.append(related.opts)
|
||||
rel_opts_name = related.get_accessor_name()
|
||||
has_related_objs = False
|
||||
collector = NestedObjects()
|
||||
for obj in objs:
|
||||
# TODO using a private model API!
|
||||
obj._collect_sub_objects(collector)
|
||||
|
||||
# related.get_accessor_name() could return None for symmetrical relationships
|
||||
if rel_opts_name:
|
||||
rel_objs = getattr(obj, rel_opts_name, None)
|
||||
if rel_objs:
|
||||
has_related_objs = True
|
||||
# TODO This next bit is needed only because GenericRelations are
|
||||
# cascade-deleted way down in the internals in
|
||||
# DeleteQuery.delete_batch_related, instead of being found by
|
||||
# _collect_sub_objects. Refs #12593.
|
||||
from django.contrib.contenttypes import generic
|
||||
for f in obj._meta.many_to_many:
|
||||
if isinstance(f, generic.GenericRelation):
|
||||
rel_manager = f.value_from_object(obj)
|
||||
for related in rel_manager.all():
|
||||
# There's a wierdness here in the case that the
|
||||
# generic-related object also has FKs pointing to it
|
||||
# from elsewhere. DeleteQuery does not follow those
|
||||
# FKs or delete any such objects explicitly (which is
|
||||
# probably a bug). Some databases may cascade those
|
||||
# deletes themselves, and some won't. So do we report
|
||||
# those objects as to-be-deleted? No right answer; for
|
||||
# now we opt to report only on objects that Django
|
||||
# will explicitly delete, at risk that some further
|
||||
# objects will be silently deleted by a
|
||||
# referential-integrity-maintaining database.
|
||||
collector.add(related.__class__, related.pk, related,
|
||||
obj.__class__, obj)
|
||||
|
||||
if has_related_objs:
|
||||
for sub_obj in rel_objs.all():
|
||||
if not has_admin:
|
||||
# Don't display link to edit, because it either has no
|
||||
# admin or is edited inline.
|
||||
nh(deleted_objects, current_depth, [_('One or more %(fieldname)s in %(name)s: %(obj)s') % \
|
||||
{'fieldname': force_unicode(related.field.verbose_name), 'name': force_unicode(related.opts.verbose_name), 'obj': escape(sub_obj)}, []])
|
||||
perms_needed = set()
|
||||
|
||||
to_delete = collector.nested(_format_callback,
|
||||
user=user,
|
||||
admin_site=admin_site,
|
||||
levels_to_root=levels_to_root,
|
||||
perms_needed=perms_needed)
|
||||
|
||||
return to_delete, perms_needed
|
||||
|
||||
|
||||
class NestedObjects(object):
|
||||
"""
|
||||
A directed acyclic graph collection that exposes the add() API
|
||||
expected by Model._collect_sub_objects and can present its data as
|
||||
a nested list of objects.
|
||||
|
||||
"""
|
||||
def __init__(self):
|
||||
# Use object keys of the form (model, pk) because actual model
|
||||
# objects may not be unique
|
||||
|
||||
# maps object key to list of child keys
|
||||
self.children = SortedDict()
|
||||
|
||||
# maps object key to parent key
|
||||
self.parents = SortedDict()
|
||||
|
||||
# maps object key to actual object
|
||||
self.seen = SortedDict()
|
||||
|
||||
def add(self, model, pk, obj,
|
||||
parent_model=None, parent_obj=None, nullable=False):
|
||||
"""
|
||||
Add item ``obj`` to the graph. Returns True (and does nothing)
|
||||
if the item has been seen already.
|
||||
|
||||
The ``parent_obj`` argument must already exist in the graph; if
|
||||
not, it's ignored (but ``obj`` is still added with no
|
||||
parent). In any case, Model._collect_sub_objects (for whom
|
||||
this API exists) will never pass a parent that hasn't already
|
||||
been added itself.
|
||||
|
||||
These restrictions in combination ensure the graph will remain
|
||||
acyclic (but can have multiple roots).
|
||||
|
||||
``model``, ``pk``, and ``parent_model`` arguments are ignored
|
||||
in favor of the appropriate lookups on ``obj`` and
|
||||
``parent_obj``; unlike CollectedObjects, we can't maintain
|
||||
independence from the knowledge that we're operating on model
|
||||
instances, and we don't want to allow for inconsistency.
|
||||
|
||||
``nullable`` arg is ignored: it doesn't affect how the tree of
|
||||
collected objects should be nested for display.
|
||||
"""
|
||||
model, pk = type(obj), obj._get_pk_val()
|
||||
|
||||
key = model, pk
|
||||
|
||||
if key in self.seen:
|
||||
return True
|
||||
self.seen.setdefault(key, obj)
|
||||
|
||||
if parent_obj is not None:
|
||||
parent_model, parent_pk = (type(parent_obj),
|
||||
parent_obj._get_pk_val())
|
||||
parent_key = (parent_model, parent_pk)
|
||||
if parent_key in self.seen:
|
||||
self.children.setdefault(parent_key, list()).append(key)
|
||||
self.parents.setdefault(key, parent_key)
|
||||
|
||||
def _nested(self, key, format_callback=None, **kwargs):
|
||||
obj = self.seen[key]
|
||||
if format_callback:
|
||||
ret = [format_callback(obj, **kwargs)]
|
||||
else:
|
||||
# Display a link to the admin page.
|
||||
nh(deleted_objects, current_depth, [
|
||||
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))}) + \
|
||||
(u' <a href="%s">%s</a>' % \
|
||||
(get_change_view_url(related.opts.app_label,
|
||||
related.opts.object_name.lower(),
|
||||
sub_obj._get_pk_val(),
|
||||
admin_site,
|
||||
levels_to_root),
|
||||
escape(sub_obj)))), []])
|
||||
# If there were related objects, and the user doesn't have
|
||||
# permission to change them, add the missing perm to perms_needed.
|
||||
if has_admin and has_related_objs:
|
||||
p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
|
||||
if not user.has_perm(p):
|
||||
perms_needed.add(related.opts.verbose_name)
|
||||
ret = [obj]
|
||||
|
||||
children = []
|
||||
for child in self.children.get(key, ()):
|
||||
children.extend(self._nested(child, format_callback, **kwargs))
|
||||
if children:
|
||||
ret.append(children)
|
||||
|
||||
return ret
|
||||
|
||||
def nested(self, format_callback=None, **kwargs):
|
||||
"""
|
||||
Return the graph as a nested list.
|
||||
|
||||
Passes **kwargs back to the format_callback as kwargs.
|
||||
|
||||
"""
|
||||
roots = []
|
||||
for key in self.seen.keys():
|
||||
if key not in self.parents:
|
||||
roots.extend(self._nested(key, format_callback, **kwargs))
|
||||
return roots
|
||||
|
||||
|
||||
def model_format_dict(obj):
|
||||
"""
|
||||
|
|
|
@ -549,7 +549,8 @@ class Model(object):
|
|||
(model_class, {pk_val: obj, pk_val: obj, ...}), ...]
|
||||
"""
|
||||
pk_val = self._get_pk_val()
|
||||
if seen_objs.add(self.__class__, pk_val, self, parent, nullable):
|
||||
if seen_objs.add(self.__class__, pk_val, self,
|
||||
type(parent), parent, nullable):
|
||||
return
|
||||
|
||||
for related in self._meta.get_all_related_objects():
|
||||
|
@ -560,7 +561,7 @@ class Model(object):
|
|||
except ObjectDoesNotExist:
|
||||
pass
|
||||
else:
|
||||
sub_obj._collect_sub_objects(seen_objs, self.__class__, related.field.null)
|
||||
sub_obj._collect_sub_objects(seen_objs, self, related.field.null)
|
||||
else:
|
||||
# To make sure we can access all elements, we can't use the
|
||||
# normal manager on the related object. So we work directly
|
||||
|
@ -578,7 +579,7 @@ class Model(object):
|
|||
continue
|
||||
delete_qs = rel_descriptor.delete_manager(self).all()
|
||||
for sub_obj in delete_qs:
|
||||
sub_obj._collect_sub_objects(seen_objs, self.__class__, related.field.null)
|
||||
sub_obj._collect_sub_objects(seen_objs, self, related.field.null)
|
||||
|
||||
# Handle any ancestors (for the model-inheritance case). We do this by
|
||||
# traversing to the most remote parent classes -- those with no parents
|
||||
|
|
|
@ -50,7 +50,7 @@ class CollectedObjects(object):
|
|||
else:
|
||||
self.blocked = {}
|
||||
|
||||
def add(self, model, pk, obj, parent_model, nullable=False):
|
||||
def add(self, model, pk, obj, parent_model, parent_obj=None, nullable=False):
|
||||
"""
|
||||
Adds an item to the container.
|
||||
|
||||
|
@ -60,6 +60,8 @@ class CollectedObjects(object):
|
|||
* obj - the object itself.
|
||||
* parent_model - the model of the parent object that this object was
|
||||
reached through.
|
||||
* parent_obj - the parent object this object was reached
|
||||
through (not used here, but needed in the API for use elsewhere)
|
||||
* nullable - should be True if this relation is nullable.
|
||||
|
||||
Returns True if the item already existed in the structure and
|
||||
|
|
|
@ -17,3 +17,6 @@ class Article(models.Model):
|
|||
def test_from_model_with_override(self):
|
||||
return "nothing"
|
||||
test_from_model_with_override.short_description = "not what you expect"
|
||||
|
||||
class Count(models.Model):
|
||||
num = models.PositiveSmallIntegerField()
|
||||
|
|
|
@ -2,16 +2,69 @@ from datetime import datetime
|
|||
import unittest
|
||||
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.test import TestCase
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.util import display_for_field, label_for_field, lookup_field
|
||||
from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
|
||||
from django.contrib.sites.models import Site
|
||||
from django.utils.formats import localize
|
||||
from django.contrib.admin.util import NestedObjects
|
||||
|
||||
from models import Article
|
||||
from models import Article, Count
|
||||
|
||||
|
||||
class NestedObjectsTests(TestCase):
|
||||
"""
|
||||
Tests for ``NestedObject`` utility collection.
|
||||
|
||||
"""
|
||||
def setUp(self):
|
||||
self.n = NestedObjects()
|
||||
self.objs = [Count.objects.create(num=i) for i in range(5)]
|
||||
|
||||
def _check(self, target):
|
||||
self.assertEquals(self.n.nested(lambda obj: obj.num), target)
|
||||
|
||||
def _add(self, obj, parent=None):
|
||||
# don't bother providing the extra args that NestedObjects ignores
|
||||
self.n.add(None, None, obj, None, parent)
|
||||
|
||||
def test_unrelated_roots(self):
|
||||
self._add(self.objs[0])
|
||||
self._add(self.objs[1])
|
||||
self._add(self.objs[2], self.objs[1])
|
||||
|
||||
self._check([0, 1, [2]])
|
||||
|
||||
def test_siblings(self):
|
||||
self._add(self.objs[0])
|
||||
self._add(self.objs[1], self.objs[0])
|
||||
self._add(self.objs[2], self.objs[0])
|
||||
|
||||
self._check([0, [1, 2]])
|
||||
|
||||
def test_duplicate_instances(self):
|
||||
self._add(self.objs[0])
|
||||
self._add(self.objs[1])
|
||||
dupe = Count.objects.get(num=1)
|
||||
self._add(dupe, self.objs[0])
|
||||
|
||||
self._check([0, 1])
|
||||
|
||||
def test_non_added_parent(self):
|
||||
self._add(self.objs[0], self.objs[1])
|
||||
|
||||
self._check([0])
|
||||
|
||||
def test_cyclic(self):
|
||||
self._add(self.objs[0], self.objs[2])
|
||||
self._add(self.objs[1], self.objs[0])
|
||||
self._add(self.objs[2], self.objs[1])
|
||||
self._add(self.objs[0], self.objs[2])
|
||||
|
||||
self._check([0, [1, [2]]])
|
||||
|
||||
|
||||
class UtilTests(unittest.TestCase):
|
||||
def test_values_from_lookup_field(self):
|
||||
|
|
|
@ -10,7 +10,8 @@ from django.core.mail import EmailMessage
|
|||
from django.db import models
|
||||
from django import forms
|
||||
from django.forms.models import BaseModelFormSet
|
||||
|
||||
from django.contrib.contenttypes import generic
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
class Section(models.Model):
|
||||
"""
|
||||
|
@ -494,6 +495,71 @@ class GadgetAdmin(admin.ModelAdmin):
|
|||
def get_changelist(self, request, **kwargs):
|
||||
return CustomChangeList
|
||||
|
||||
class Villain(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
class SuperVillain(Villain):
|
||||
pass
|
||||
|
||||
class FunkyTag(models.Model):
|
||||
"Because we all know there's only one real use case for GFKs."
|
||||
name = models.CharField(max_length=25)
|
||||
content_type = models.ForeignKey(ContentType)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = generic.GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
class Plot(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
team_leader = models.ForeignKey(Villain, related_name='lead_plots')
|
||||
contact = models.ForeignKey(Villain, related_name='contact_plots')
|
||||
tags = generic.GenericRelation(FunkyTag)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
class PlotDetails(models.Model):
|
||||
details = models.CharField(max_length=100)
|
||||
plot = models.OneToOneField(Plot)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.details
|
||||
|
||||
class SecretHideout(models.Model):
|
||||
""" Secret! Not registered with the admin! """
|
||||
location = models.CharField(max_length=100)
|
||||
villain = models.ForeignKey(Villain)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.location
|
||||
|
||||
class SuperSecretHideout(models.Model):
|
||||
""" Secret! Not registered with the admin! """
|
||||
location = models.CharField(max_length=100)
|
||||
supervillain = models.ForeignKey(SuperVillain)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.location
|
||||
|
||||
class CyclicOne(models.Model):
|
||||
name = models.CharField(max_length=25)
|
||||
two = models.ForeignKey('CyclicTwo')
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
class CyclicTwo(models.Model):
|
||||
name = models.CharField(max_length=25)
|
||||
one = models.ForeignKey(CyclicOne)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
admin.site.register(Article, ArticleAdmin)
|
||||
admin.site.register(CustomArticle, CustomArticleAdmin)
|
||||
admin.site.register(Section, save_as=True, inlines=[ArticleInline])
|
||||
|
@ -519,6 +585,12 @@ admin.site.register(Collector, CollectorAdmin)
|
|||
admin.site.register(Category, CategoryAdmin)
|
||||
admin.site.register(Post, PostAdmin)
|
||||
admin.site.register(Gadget, GadgetAdmin)
|
||||
admin.site.register(Villain)
|
||||
admin.site.register(SuperVillain)
|
||||
admin.site.register(Plot)
|
||||
admin.site.register(PlotDetails)
|
||||
admin.site.register(CyclicOne)
|
||||
admin.site.register(CyclicTwo)
|
||||
|
||||
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
|
||||
# That way we cover all four cases:
|
||||
|
|
|
@ -16,13 +16,14 @@ from django.utils import formats
|
|||
from django.utils.cache import get_max_age
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import get_date_formats
|
||||
from django.utils.encoding import iri_to_uri
|
||||
|
||||
# local test models
|
||||
from models import Article, BarAccount, CustomArticle, EmptyModel, \
|
||||
ExternalSubscriber, FooAccount, Gallery, ModelWithStringPrimaryKey, \
|
||||
Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
|
||||
Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
|
||||
Category, Post
|
||||
Category, Post, Plot, FunkyTag
|
||||
|
||||
|
||||
class AdminViewBasicTest(TestCase):
|
||||
|
@ -637,6 +638,113 @@ class AdminViewPermissionsTest(TestCase):
|
|||
response = self.client.get('/test_admin/admin/secure-view/')
|
||||
self.assertContains(response, 'id="login-form"')
|
||||
|
||||
|
||||
class AdminViewDeletedObjectsTest(TestCase):
|
||||
fixtures = ['admin-views-users.xml', 'deleted-objects.xml']
|
||||
|
||||
def setUp(self):
|
||||
self.client.login(username='super', password='secret')
|
||||
|
||||
def tearDown(self):
|
||||
self.client.logout()
|
||||
|
||||
def test_nesting(self):
|
||||
"""
|
||||
Objects should be nested to display the relationships that
|
||||
cause them to be scheduled for deletion.
|
||||
"""
|
||||
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>""")
|
||||
response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
|
||||
self.failUnless(pattern.search(response.content))
|
||||
|
||||
def test_cyclic(self):
|
||||
"""
|
||||
Cyclic relationships should still cause each object to only be
|
||||
listed once.
|
||||
|
||||
"""
|
||||
one = """<li>Cyclic one: <a href="/test_admin/admin/admin_views/cyclicone/1/">I am recursive</a>"""
|
||||
two = """<li>Cyclic two: <a href="/test_admin/admin/admin_views/cyclictwo/1/">I am recursive too</a>"""
|
||||
response = self.client.get('/test_admin/admin/admin_views/cyclicone/%s/delete/' % quote(1))
|
||||
|
||||
self.assertContains(response, one, 1)
|
||||
self.assertContains(response, two, 1)
|
||||
|
||||
def test_perms_needed(self):
|
||||
self.client.logout()
|
||||
delete_user = User.objects.get(username='deleteuser')
|
||||
delete_user.user_permissions.add(get_perm(Plot,
|
||||
Plot._meta.get_delete_permission()))
|
||||
|
||||
self.failUnless(self.client.login(username='deleteuser',
|
||||
password='secret'))
|
||||
|
||||
response = self.client.get('/test_admin/admin/admin_views/plot/%s/delete/' % quote(1))
|
||||
self.assertContains(response, "your account doesn't have permission to delete the following types of objects")
|
||||
self.assertContains(response, "<li>plot details</li>")
|
||||
|
||||
|
||||
def test_not_registered(self):
|
||||
should_contain = """<li>Secret hideout: underground bunker"""
|
||||
response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
|
||||
self.assertContains(response, should_contain, 1)
|
||||
|
||||
def test_multiple_fkeys_to_same_model(self):
|
||||
"""
|
||||
If a deleted object has two relationships from another model,
|
||||
both of those should be followed in looking for related
|
||||
objects to delete.
|
||||
|
||||
"""
|
||||
should_contain = """<li>Plot: <a href="/test_admin/admin/admin_views/plot/1/">World Domination</a>"""
|
||||
response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
|
||||
self.assertContains(response, should_contain)
|
||||
response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(2))
|
||||
self.assertContains(response, should_contain)
|
||||
|
||||
def test_multiple_fkeys_to_same_instance(self):
|
||||
"""
|
||||
If a deleted object has two relationships pointing to it from
|
||||
another object, the other object should still only be listed
|
||||
once.
|
||||
|
||||
"""
|
||||
should_contain = """<li>Plot: <a href="/test_admin/admin/admin_views/plot/2/">World Peace</a></li>"""
|
||||
response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(2))
|
||||
self.assertContains(response, should_contain, 1)
|
||||
|
||||
def test_inheritance(self):
|
||||
"""
|
||||
In the case of an inherited model, if either the child or
|
||||
parent-model instance is deleted, both instances are listed
|
||||
for deletion, as well as any relationships they have.
|
||||
|
||||
"""
|
||||
should_contain = [
|
||||
"""<li>Villain: <a href="/test_admin/admin/admin_views/villain/3/">Bob</a>""",
|
||||
"""<li>Super villain: <a href="/test_admin/admin/admin_views/supervillain/3/">Bob</a>""",
|
||||
"""<li>Secret hideout: floating castle""",
|
||||
"""<li>Super secret hideout: super floating castle!"""
|
||||
]
|
||||
response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(3))
|
||||
for should in should_contain:
|
||||
self.assertContains(response, should, 1)
|
||||
response = self.client.get('/test_admin/admin/admin_views/supervillain/%s/delete/' % quote(3))
|
||||
for should in should_contain:
|
||||
self.assertContains(response, should, 1)
|
||||
|
||||
def test_generic_relations(self):
|
||||
"""
|
||||
If a deleted object has GenericForeignKeys pointing to it,
|
||||
those objects should be listed for deletion.
|
||||
|
||||
"""
|
||||
plot = Plot.objects.get(pk=3)
|
||||
tag = FunkyTag.objects.create(content_object=plot, name='hott')
|
||||
should_contain = """<li>Funky tag: hott"""
|
||||
response = self.client.get('/test_admin/admin/admin_views/plot/%s/delete/' % quote(3))
|
||||
self.assertContains(response, should_contain)
|
||||
|
||||
class AdminViewStringPrimaryKeyTest(TestCase):
|
||||
fixtures = ['admin-views-users.xml', 'string-primary-key.xml']
|
||||
|
||||
|
@ -699,7 +807,8 @@ class AdminViewStringPrimaryKeyTest(TestCase):
|
|||
def test_deleteconfirmation_link(self):
|
||||
"The link from the delete confirmation page referring back to the changeform of the object should be quoted"
|
||||
response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/%s/delete/' % quote(self.pk))
|
||||
should_contain = """<a href="../../%s/">%s</a>""" % (quote(self.pk), escape(self.pk))
|
||||
# this URL now comes through reverse(), thus iri_to_uri encoding
|
||||
should_contain = """/%s/">%s</a>""" % (iri_to_uri(quote(self.pk)), escape(self.pk))
|
||||
self.assertContains(response, should_contain)
|
||||
|
||||
def test_url_conflicts_with_add(self):
|
||||
|
|
Loading…
Reference in New Issue