UI improvements when deleting objects.

Shows nested child objects that will be deleted when deleting a single object or multiple objects.

Includes some hacky tests for NestedObjects in the example app.
This commit is contained in:
Ben Tappin 2013-05-25 15:52:58 +01:00
parent 5fcccb6183
commit 11f193ffc9
9 changed files with 215 additions and 12 deletions

View file

@ -1,6 +1,7 @@
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse
from django.utils.encoding import force_text
from django.utils.text import capfirst
from . import utils
@ -51,9 +52,19 @@ def delete_selected(request, queryset):
# render a template asking for their confirmation.
if has_permission:
template = 'admin2/bootstrap/delete_selected_confirmation.html'
def _format_callback(obj):
opts = obj._meta
return '%s: %s' % (force_text(capfirst(opts.verbose_name)),
force_text(obj))
collector = utils.NestedObjects(using=None)
collector.collect(queryset)
context = {
'queryset': queryset,
'objects_name': objects_name
'objects_name': objects_name,
'deletable_objects': collector.nested(_format_callback),
}
return TemplateResponse(request, template, context)
else:

View file

@ -1,13 +1,16 @@
{% extends "admin2/bootstrap/base.html" %}
{% load i18n %}
{% block title %}Are you sure?{% endblock %}
{% block page_title %}Are you sure?{% endblock %}
{% block content %}
<p>{% blocktrans with objects_name=objects_name %}Are you sure you want to delete the selected {{ objects_name }}?{% endblocktrans %}</p>
<p>{% blocktrans with objects_name=objects_name %}Are you sure you want to delete the selected {{ objects_name }}? All of the following items will be deleted:{% endblocktrans %}</p>
<ul>
{% for item in queryset %}
<li>{{ item }}</li>
{% endfor %}
{{ deletable_objects|unordered_list }}
</ul>
<form method="post">
@ -15,9 +18,9 @@
<input type="hidden" name="confirmed" value="yes" />
<input type="hidden" name="action" value="delete_selected" />
{% for item in queryset %}
<input type="hidden" name="selected_model_pk" value="{{ item.pk }}" />
<input type="hidden" name="selected_model_pk" value="{{ item.pk }}" />
{% endfor %}
<input type="submit"/>
<button class="btn btn-small btn-danger" type="submit">{% trans "Yes, I'm sure" %}</button>
</form>
{% endblock content %}

View file

@ -14,14 +14,17 @@
{% endblock %}
{% block content %}
<p>{% blocktrans with model_name=model_name object=object %}Are you sure you want to delete the {{ model_name }} "{{ object }}"? All of the following related items will be deleted:{% endblocktrans %}</p>
TODO
<p>{% blocktrans with model_name=model_name object=object %}Are you sure you want to delete the {{ model_name }} "{{ object }}"? All of the following items will be deleted:{% endblocktrans %}</p>
<ul>
{{ deletable_objects|unordered_list }}
</ul>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit"/>
<button class="btn btn-small btn-danger" type="submit">{% trans "Yes, I'm sure" %}</button>
</form>
{% endblock content %}

View file

@ -1,3 +1,6 @@
from django.db.models.deletion import Collector
def model_options(model):
"""
Wrapper for accessing model._meta. If this access point changes in core
@ -34,4 +37,65 @@ def model_app_label(obj):
"""
Returns the app label of a model instance or class.
"""
return model_options(obj).app_label
return model_options(obj).app_label
# Taken from the Django core.
# https://github.com/django/django/blob/1.5.1/django/contrib/admin/util.py#L144
class NestedObjects(Collector):
def __init__(self, *args, **kwargs):
super(NestedObjects, self).__init__(*args, **kwargs)
self.edges = {} # {from_instance: [to_instances]}
self.protected = set()
def add_edge(self, source, target):
self.edges.setdefault(source, []).append(target)
def collect(self, objs, source_attr=None, **kwargs):
for obj in objs:
if source_attr:
self.add_edge(getattr(obj, source_attr), obj)
else:
self.add_edge(None, 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

View file

@ -1,10 +1,13 @@
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.utils.encoding import force_text
from django.utils.text import capfirst
from django.views import generic
import extra_views
from . import permissions
from .utils import NestedObjects
from .viewmixins import Admin2Mixin, AdminModel2Mixin, Admin2ModelFormMixin
@ -111,3 +114,18 @@ class ModelDeleteView(AdminModel2Mixin, generic.DeleteView):
permission_classes = (
permissions.IsStaffPermission,
permissions.ModelDeletePermission)
def get_context_data(self, **kwargs):
context = super(ModelDeleteView, self).get_context_data(**kwargs)
def _format_callback(obj):
opts = obj._meta
return '%s: %s' % (force_text(capfirst(opts.verbose_name)),
force_text(obj))
collector = NestedObjects(using=None)
collector.collect([self.get_object()])
context.update({
'deletable_objects': collector.nested(_format_callback)
})
return context

View file

@ -1,4 +1,6 @@
from django.db import models
from django.utils import six
from django.utils.encoding import python_2_unicode_compatible
class Post(models.Model):
@ -15,3 +17,34 @@ class Comment(models.Model):
def __unicode__(self):
return self.body
#### Models needed for testing NestedObjects
@python_2_unicode_compatible
class Count(models.Model):
num = models.PositiveSmallIntegerField()
parent = models.ForeignKey('self', null=True)
def __str__(self):
return six.text_type(self.num)
class Event(models.Model):
date = models.DateTimeField(auto_now_add=True)
class Location(models.Model):
event = models.OneToOneField(Event, verbose_name='awesome event')
class Guest(models.Model):
event = models.OneToOneField(Event)
name = models.CharField(max_length=255)
class Meta:
verbose_name = "awesome guest"
class EventGuide(models.Model):
event = models.ForeignKey(Event, on_delete=models.DO_NOTHING)

View file

@ -4,3 +4,4 @@ from test_builtin_api_resources import *
from test_permissions import *
from test_modelforms import *
from test_views import *
from test_nestedobjects import *

View file

@ -0,0 +1,70 @@
from django.db import DEFAULT_DB_ALIAS
from django.test import TestCase
from djadmin2.utils import NestedObjects
from ..models import Count, Event, EventGuide, Guest, Location
# Largely taken from the Django core.
# https://github.com/django/django/blob/1.5.1/tests/regressiontests/admin_util/tests.py
class NestedObjectsTests(TestCase):
"""
Tests for ``NestedObject`` utility collection.
"""
def setUp(self):
self.n = NestedObjects(using=DEFAULT_DB_ALIAS)
self.objs = [Count.objects.create(num=i) for i in range(5)]
def _check(self, target):
self.assertEqual(self.n.nested(lambda obj: obj.num), target)
def _connect(self, i, j):
self.objs[i].parent = self.objs[j]
self.objs[i].save()
def _collect(self, *indices):
self.n.collect([self.objs[i] for i in indices])
def test_unrelated_roots(self):
self._connect(2, 1)
self._collect(0)
self._collect(1)
self._check([0, 1, [2]])
def test_siblings(self):
self._connect(1, 0)
self._connect(2, 0)
self._collect(0)
self._check([0, [1, 2]])
def test_non_added_parent(self):
self._connect(0, 1)
self._collect(0)
self._check([0])
def test_cyclic(self):
self._connect(0, 2)
self._connect(1, 0)
self._connect(2, 1)
self._collect(0)
self._check([0, [1, [2]]])
def test_queries(self):
self._connect(1, 0)
self._connect(2, 0)
# 1 query to fetch all children of 0 (1 and 2)
# 1 query to fetch all children of 1 and 2 (none)
# Should not require additional queries to populate the nested graph.
self.assertNumQueries(2, self._collect, 0)
def test_on_delete_do_nothing(self):
"""
Check that the nested collector doesn't query for DO_NOTHING objects.
"""
objs = [Event.objects.create()]
n = NestedObjects(using=None)
EventGuide.objects.create(event=objs[0])
with self.assertNumQueries(2):
# One for Location, one for Guest, and no query for EventGuide
n.collect(objs)

View file

@ -38,7 +38,7 @@ class PostListTest(BaseIntegrationTest):
post = Post.objects.create(title="a_post_title", body="body")
params = {'action': 'delete_selected', 'selected_model_pk': str(post.pk)}
response = self.client.post(reverse("admin2:blog_post_index"), params)
self.assertInHTML('<p>Are you sure you want to delete the selected post?</p>', response.content)
self.assertInHTML('<p>Are you sure you want to delete the selected post? All of the following items will be deleted:</p>', response.content)
def test_delete_selected_post_confirmation(self):
post = Post.objects.create(title="a_post_title", body="body")