mirror of
https://github.com/jazzband/django-admin2.git
synced 2026-05-01 03:54:47 +00:00
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:
parent
5fcccb6183
commit
11f193ffc9
9 changed files with 215 additions and 12 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 *
|
||||
|
|
|
|||
70
example/blog/tests/test_nestedobjects.py
Normal file
70
example/blog/tests/test_nestedobjects.py
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue