Skip to content

Commit

Permalink
Fixed #6191, #11296 -- Modified the admin deletion confirmation page …
Browse files Browse the repository at this point in the history
…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
russellm committed Feb 26, 2010
1 parent ad49d15 commit 4ad90c4
Show file tree
Hide file tree
Showing 10 changed files with 417 additions and 143 deletions.
12 changes: 2 additions & 10 deletions django/contrib/admin/actions.py
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
4 changes: 1 addition & 3 deletions django/contrib/admin/options.py
Expand Up @@ -1080,9 +1080,7 @@ def delete_view(self, request, object_id, extra_context=None):

# 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:
Expand Down
Expand Up @@ -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>
Expand Down
282 changes: 163 additions & 119 deletions django/contrib/admin/util.py
Expand Up @@ -7,7 +7,7 @@
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):
"""
Expand Down Expand Up @@ -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.
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.
`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)
collector = NestedObjects()
for obj in objs:
# TODO using a private model API!
obj._collect_sub_objects(collector)

# 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)

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:
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

# 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

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)}, []])
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):
"""
Expand Down
7 changes: 4 additions & 3 deletions django/db/models/base.py
Expand Up @@ -549,7 +549,8 @@ def _collect_sub_objects(self, seen_objs, parent=None, nullable=False):
(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():
Expand All @@ -560,7 +561,7 @@ def _collect_sub_objects(self, seen_objs, parent=None, nullable=False):
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
Expand All @@ -578,7 +579,7 @@ def _collect_sub_objects(self, seen_objs, parent=None, nullable=False):
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
Expand Down

0 comments on commit 4ad90c4

Please sign in to comment.