diff --git a/AUTHORS b/AUTHORS index fb32b97887..2a2ef3ab3d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -677,6 +677,7 @@ answer newbie questions, and generally made Django that much better: Raphaël Barrois Raphael Michel Raúl Cumplido + Rebecca Smith Remco Wendt Renaud Parent Renbi Yu diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py index d4bc3daf85..f64b89205e 100644 --- a/django/contrib/admin/actions.py +++ b/django/contrib/admin/actions.py @@ -4,7 +4,7 @@ Built-in, globally-available admin actions. from django.contrib import messages from django.contrib.admin import helpers -from django.contrib.admin.utils import get_deleted_objects, model_ngettext +from django.contrib.admin.utils import model_ngettext from django.core.exceptions import PermissionDenied from django.template.response import TemplateResponse from django.utils.translation import gettext as _, gettext_lazy @@ -29,9 +29,7 @@ def delete_selected(modeladmin, request, queryset): # Populate deletable_objects, a data structure of all related objects that # will also be deleted. - deletable_objects, model_count, perms_needed, protected = get_deleted_objects( - queryset, request.user, modeladmin.admin_site, - ) + deletable_objects, model_count, perms_needed, protected = modeladmin.get_deleted_objects(queryset, request) # The user has already confirmed the deletion. # Do the deletion and return None to display the change list view again. diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index f9f5f85ef3..bbc402bd74 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1728,6 +1728,13 @@ class ModelAdmin(BaseModelAdmin): 'admin/change_list.html' ], context) + def get_deleted_objects(self, objs, request): + """ + Hook for customizing the delete process for the delete view and the + "delete selected" action. + """ + return get_deleted_objects(objs, request.user, self.admin_site) + @csrf_protect_m def delete_view(self, request, object_id, extra_context=None): with transaction.atomic(using=router.db_for_write(self.model)): @@ -1752,9 +1759,7 @@ class ModelAdmin(BaseModelAdmin): # Populate deleted_objects, a data structure of all related objects that # will also be deleted. - deleted_objects, model_count, perms_needed, protected = get_deleted_objects( - [obj], request.user, self.admin_site, - ) + deleted_objects, model_count, perms_needed, protected = self.get_deleted_objects([obj], request) if request.POST and not protected: # The user has confirmed the deletion. if perms_needed: diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index d586513c9a..d1138076ff 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -1998,6 +1998,36 @@ templates used by the :class:`ModelAdmin` views: def get_changeform_initial_data(self, request): return {'name': 'custom_initial_value'} +.. method:: ModelAdmin.get_deleted_objects(objs, request) + + .. versionadded:: 2.1 + + A hook for customizing the deletion process of the :meth:`delete_view` and + the "delete selected" :doc:`action `. + + The ``objs`` argument is a homogeneous iterable of objects (a ``QuerySet`` + or a list of model instances) to be deleted, and ``request`` is the + :class:`~django.http.HttpRequest`. + + This method must return a 4-tuple of + ``(deleted_objects, model_count, perms_needed, protected)``. + + ``deleted_objects`` is a list of strings representing all the objects that + will be deleted. If there are any related objects to be deleted, the list + is nested and includes those related objects. The list is formatted in the + template using the :tfilter:`unordered_list` filter. + + ``model_count`` is a dictionary mapping each model's + :attr:`~django.db.models.Options.verbose_name_plural` to the number of + objects that will be deleted. + + ``perms_needed`` is a set of :attr:`~django.db.models.Options.verbose_name`\s + of the models that the user doesn't have permission to delete. + + ``protected`` is a list of strings representing of all the protected + related objects that can't be deleted. The list is displayed in the + template. + Other methods ~~~~~~~~~~~~~ diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index 0963862f17..0a7d6da6cb 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -50,6 +50,9 @@ Minor features * The ``admin_order_field`` attribute for elements in :attr:`.ModelAdmin.list_display` may now be a query expression. +* The new :meth:`.ModelAdmin.get_deleted_objects()` method allows customizing + the deletion process of the delete view and the "delete selected" action. + :mod:`django.contrib.admindocs` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/admin_views/customadmin.py b/tests/admin_views/customadmin.py index 5ee8c0c159..9331918b37 100644 --- a/tests/admin_views/customadmin.py +++ b/tests/admin_views/customadmin.py @@ -46,9 +46,15 @@ class CustomPwdTemplateUserAdmin(UserAdmin): change_user_password_template = ['admin/auth/user/change_password.html'] # a list, to test fix for #18697 +class BookAdmin(admin.ModelAdmin): + def get_deleted_objects(self, objs, request): + return ['a deletable object'], {'books': 1}, set(), [] + + site = Admin2(name="admin2") site.register(models.Article, base_admin.ArticleAdmin) +site.register(models.Book, BookAdmin) site.register(models.Section, inlines=[base_admin.ArticleInline], search_fields=['name']) site.register(models.Thing, base_admin.ThingAdmin) site.register(models.Fabric, base_admin.FabricAdmin) diff --git a/tests/admin_views/test_actions.py b/tests/admin_views/test_actions.py index 4823316e3e..423c2f927f 100644 --- a/tests/admin_views/test_actions.py +++ b/tests/admin_views/test_actions.py @@ -12,7 +12,7 @@ from django.urls import reverse from .admin import SubscriberAdmin from .forms import MediaActionForm from .models import ( - Actor, Answer, ExternalSubscriber, Question, Subscriber, + Actor, Answer, Book, ExternalSubscriber, Question, Subscriber, UnchangeableObject, ) @@ -153,6 +153,18 @@ class AdminActionsTest(TestCase): self.assertIs(SubscriberAdmin.overridden, True) self.assertEqual(Subscriber.objects.all().count(), 0) + def test_delete_selected_uses_get_deleted_objects(self): + """The delete_selected action uses ModelAdmin.get_deleted_objects().""" + book = Book.objects.create(name='Test Book') + data = { + ACTION_CHECKBOX_NAME: [book.pk], + 'action': 'delete_selected', + 'index': 0, + } + response = self.client.post(reverse('admin2:admin_views_book_changelist'), data) + # BookAdmin.get_deleted_objects() returns custom text. + self.assertContains(response, 'a deletable object') + def test_custom_function_mail_action(self): """A custom action may be defined in a function.""" action_data = { diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 011bb2897a..3985e16b47 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -2357,6 +2357,13 @@ class AdminViewDeletedObjectsTest(TestCase): response = self.client.get(reverse('admin:admin_views_bookmark_delete', args=(bookmark.pk,))) self.assertContains(response, should_contain) + def test_delete_view_uses_get_deleted_objects(self): + """The delete view uses ModelAdmin.get_deleted_objects().""" + book = Book.objects.create(name='Test Book') + response = self.client.get(reverse('admin2:admin_views_book_delete', args=(book.pk,))) + # BookAdmin.get_deleted_objects() returns custom text. + self.assertContains(response, 'a deletable object') + @override_settings(ROOT_URLCONF='admin_views.urls') class TestGenericRelations(TestCase): diff --git a/tests/modeladmin/tests.py b/tests/modeladmin/tests.py index 202929df05..81c28cb0ff 100644 --- a/tests/modeladmin/tests.py +++ b/tests/modeladmin/tests.py @@ -666,6 +666,16 @@ class ModelAdminTests(TestCase): finally: self.site.unregister(Band) + def test_get_deleted_objects(self): + mock_request = MockRequest() + mock_request.user = User.objects.create_superuser(username='bob', email='bob@test.com', password='test') + ma = ModelAdmin(Band, self.site) + deletable_objects, model_count, perms_needed, protected = ma.get_deleted_objects([self.band], request) + self.assertEqual(deletable_objects, ['Band: The Doors']) + self.assertEqual(model_count, {'bands': 1}) + self.assertEqual(perms_needed, set()) + self.assertEqual(protected, []) + class ModelAdminPermissionTests(SimpleTestCase):