summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRodrigo Vieira <rodrigo.vieira@gmail.com>2026-04-22 18:53:27 -0300
committerJacob Walls <jacobtylerwalls@gmail.com>2026-04-22 22:22:55 -0400
commitfa2a3de6ede10b005fc2c1d23f4cffb53eaec425 (patch)
tree2fbb49592272a9b5b54ae9457ba0e2072dabe74f
parenta586f03f36f511064f171c0e30f4ca2ebfd60085 (diff)
Fixed #10919 -- Added delete_confirmation_max_display to ModelAdmin.
The new ModelAdmin.delete_confirmation_max_display attribute allows limiting the number of related objects shown on the delete confirmation page. When the limit is reached, a "…and N more objects." message is shown. The feature relies on a new truncated_unordered_list template filter added to django.contrib.admin.templatetags.admin_filters. Thanks Jacob Tyler Walls for the review and guidance, Tobias McNulty for the report, and terminator14 for the solution suggested.
-rw-r--r--django/contrib/admin/actions.py1
-rw-r--r--django/contrib/admin/checks.py20
-rw-r--r--django/contrib/admin/options.py2
-rw-r--r--django/contrib/admin/templates/admin/delete_confirmation.html8
-rw-r--r--django/contrib/admin/templates/admin/delete_selected_confirmation.html8
-rw-r--r--django/contrib/admin/utils.py3
-rw-r--r--docs/ref/checks.txt2
-rw-r--r--docs/ref/contrib/admin/index.txt16
-rw-r--r--docs/releases/6.1.txt5
-rw-r--r--tests/admin_views/customadmin.py16
-rw-r--r--tests/admin_views/test_actions.py13
-rw-r--r--tests/admin_views/tests.py20
-rw-r--r--tests/admin_views/urls.py1
-rw-r--r--tests/modeladmin/test_checks.py44
14 files changed, 150 insertions, 9 deletions
diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py
index 04a906542a..a2b92e492d 100644
--- a/django/contrib/admin/actions.py
+++ b/django/contrib/admin/actions.py
@@ -70,6 +70,7 @@ def delete_selected(modeladmin, request, queryset):
"subtitle": None,
"objects_name": str(objects_name),
"deletable_objects": [deletable_objects],
+ "delete_confirmation_max_display": modeladmin.delete_confirmation_max_display,
"model_count": dict(model_count).items(),
"queryset": queryset,
"perms_lacking": perms_needed,
diff --git a/django/contrib/admin/checks.py b/django/contrib/admin/checks.py
index d14515e8a4..7baaa38a29 100644
--- a/django/contrib/admin/checks.py
+++ b/django/contrib/admin/checks.py
@@ -842,6 +842,7 @@ class ModelAdminChecks(BaseModelAdminChecks):
*self._check_search_fields(admin_obj),
*self._check_date_hierarchy(admin_obj),
*self._check_actions(admin_obj),
+ *self._check_delete_confirmation_max_display(admin_obj),
]
def _check_save_as(self, obj):
@@ -1089,6 +1090,25 @@ class ModelAdminChecks(BaseModelAdminChecks):
else:
return []
+ def _check_delete_confirmation_max_display(self, obj):
+ """Check that delete_confirmation_max_display is
+ a non-negative integer or None."""
+
+ if obj.delete_confirmation_max_display is None:
+ return []
+ if (
+ not isinstance(obj.delete_confirmation_max_display, int)
+ or obj.delete_confirmation_max_display < 0
+ ):
+ return must_be(
+ "a non-negative integer or None",
+ option="delete_confirmation_max_display",
+ obj=obj,
+ id="admin.E131",
+ )
+ else:
+ return []
+
def _check_list_per_page(self, obj):
"""Check that list_per_page is an integer."""
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 71d4a2d55c..e5502c42d5 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -662,6 +662,7 @@ class ModelAdmin(BaseModelAdmin):
add_form_template = None
change_form_template = None
change_list_template = None
+ delete_confirmation_max_display = None
delete_confirmation_template = None
delete_selected_confirmation_template = None
object_history_template = None
@@ -2287,6 +2288,7 @@ class ModelAdmin(BaseModelAdmin):
"object": obj,
"escaped_object": display_for_value(str(obj), EMPTY_VALUE_STRING),
"deleted_objects": deleted_objects,
+ "delete_confirmation_max_display": self.delete_confirmation_max_display,
"model_count": dict(model_count).items(),
"perms_lacking": perms_needed,
"protected": protected,
diff --git a/django/contrib/admin/templates/admin/delete_confirmation.html b/django/contrib/admin/templates/admin/delete_confirmation.html
index 07823db373..1c34a84d95 100644
--- a/django/contrib/admin/templates/admin/delete_confirmation.html
+++ b/django/contrib/admin/templates/admin/delete_confirmation.html
@@ -23,19 +23,21 @@
{% if perms_lacking %}
{% block delete_forbidden %}
<p>{% blocktranslate %}Deleting the {{ object_name }} “{{ escaped_object }}” would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}</p>
- <ul id="deleted-objects">{{ perms_lacking|unordered_list }}</ul>
+ <ul id="deleted-objects">{{ perms_lacking|truncated_unordered_list:delete_confirmation_max_display }}</ul>
{% endblock %}
{% elif protected %}
{% block delete_protected %}
<p>{% blocktranslate %}Deleting the {{ object_name }} “{{ escaped_object }}” would require deleting the following protected related objects:{% endblocktranslate %}</p>
- <ul id="deleted-objects">{{ protected|unordered_list }}</ul>
+ <ul id="deleted-objects">{{ protected|truncated_unordered_list:delete_confirmation_max_display }}</ul>
{% endblock %}
{% else %}
{% block delete_confirm %}
<p>{% blocktranslate %}Are you sure you want to delete the {{ object_name }} “{{ escaped_object }}”? All of the following related items will be deleted:{% endblocktranslate %}</p>
{% include "admin/includes/object_delete_summary.html" %}
+ {% if delete_confirmation_max_display is None or delete_confirmation_max_display > 0 %}
<h2>{% translate "Objects" %}</h2>
- <ul id="deleted-objects">{{ deleted_objects|unordered_list }}</ul>
+ <ul id="deleted-objects">{{ deleted_objects|truncated_unordered_list:delete_confirmation_max_display }}</ul>
+ {% endif %}
<form method="post">{% csrf_token %}
<div>
<input type="hidden" name="post" value="yes">
diff --git a/django/contrib/admin/templates/admin/delete_selected_confirmation.html b/django/contrib/admin/templates/admin/delete_selected_confirmation.html
index c6d0566883..40cfdbcc4c 100644
--- a/django/contrib/admin/templates/admin/delete_selected_confirmation.html
+++ b/django/contrib/admin/templates/admin/delete_selected_confirmation.html
@@ -1,5 +1,5 @@
{% extends "admin/base_site.html" %}
-{% load i18n l10n admin_urls static %}
+{% load i18n l10n admin_urls static admin_filters %}
{% block extrahead %}
{{ block.super }}
@@ -21,16 +21,16 @@
{% block content %}
{% if perms_lacking %}
<p>{% blocktranslate %}Deleting the selected {{ objects_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}</p>
- <ul>{{ perms_lacking|unordered_list }}</ul>
+ <ul>{{ perms_lacking|truncated_unordered_list:delete_confirmation_max_display }}</ul>
{% elif protected %}
<p>{% blocktranslate %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktranslate %}</p>
- <ul>{{ protected|unordered_list }}</ul>
+ <ul>{{ protected|truncated_unordered_list:delete_confirmation_max_display }}</ul>
{% else %}
<p>{% blocktranslate %}Are you sure you want to delete the selected {{ objects_name }}? All of the following objects and their related items will be deleted:{% endblocktranslate %}</p>
{% include "admin/includes/object_delete_summary.html" %}
<h2>{% translate "Objects" %}</h2>
{% for deletable_object in deletable_objects %}
- <ul>{{ deletable_object|unordered_list }}</ul>
+ <ul>{{ deletable_object|truncated_unordered_list:delete_confirmation_max_display }}</ul>
{% endfor %}
<form method="post">{% csrf_token %}
<div>
diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py
index 3d5b023dea..ed6c41f52e 100644
--- a/django/contrib/admin/utils.py
+++ b/django/contrib/admin/utils.py
@@ -130,7 +130,8 @@ def get_deleted_objects(objs, request, admin_site):
must be a homogeneous iterable of objects (e.g. a QuerySet).
Return a nested list of strings suitable for display in the
- template with the ``unordered_list`` filter.
+ template with the ``unordered_list``
+ and ``truncated_unordered_list`` filters.
"""
from django.contrib.admin.options import EMPTY_VALUE_STRING
diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt
index fb9187c2ac..5b4e31420b 100644
--- a/docs/ref/checks.txt
+++ b/docs/ref/checks.txt
@@ -816,6 +816,8 @@ with the admin site:
method for the ``<action>`` action.
* **admin.E130**: ``__name__`` attributes of actions defined in
``<modeladmin>`` must be unique. Name ``<name>`` is not unique.
+* **admin.E131**: The value of ``delete_confirmation_max_display`` must be a
+ non-negative integer or ``None``.
``InlineModelAdmin``
~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
index b3c7c2c426..4b0e4a985d 100644
--- a/docs/ref/contrib/admin/index.txt
+++ b/docs/ref/contrib/admin/index.txt
@@ -1386,6 +1386,21 @@ default templates used by the :class:`ModelAdmin` views:
for displaying a confirmation page when deleting one or more objects. See
the :doc:`actions documentation</ref/contrib/admin/actions>`.
+.. attribute:: ModelAdmin.delete_confirmation_max_display
+
+ .. versionadded:: 6.1
+
+ Set ``delete_confirmation_max_display`` to control how many objects are
+ displayed on the delete confirmation pages before truncating the remainder
+ with an ellipsis.
+
+ The limit applies to the total number of displayed objects across the
+ relationship hierarchy. This is purely a display setting and does not
+ affect the total number of objects retrieved from the database.
+
+ This applies to both :meth:`delete_view` and the ``delete_selected``
+ action. By default, this is ``None`` (no truncation).
+
.. attribute:: ModelAdmin.object_history_template
Path to a custom template, used by :meth:`history_view`.
@@ -2283,6 +2298,7 @@ adds some of its own (the shared features are actually defined in the
- :attr:`~ModelAdmin.fieldsets`
- :attr:`~ModelAdmin.fields`
- :attr:`~ModelAdmin.formfield_overrides`
+- :attr:`~ModelAdmin.delete_confirmation_max_display`
- :attr:`~ModelAdmin.exclude`
- :attr:`~ModelAdmin.filter_horizontal`
- :attr:`~ModelAdmin.filter_vertical`
diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
index a4e10d2102..48f8939041 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -99,6 +99,11 @@ Minor features
preserve :ref:`named groups <field-choices-named-groups>` (e.g.
``choices=[("Group", [("1", "Item")]), ...]``).
+* The :attr:`~django.contrib.admin.ModelAdmin.delete_confirmation_max_display`
+ option allows customizing how many objects are displayed on admin delete
+ confirmation pages before the remainder is truncated. The default is
+ ``None`` (no truncation).
+
* In order to improve accessibility of the admin change forms:
* Form fields are now shown below their respective labels instead of next to
diff --git a/tests/admin_views/customadmin.py b/tests/admin_views/customadmin.py
index a63d24a9ee..9621cb4d52 100644
--- a/tests/admin_views/customadmin.py
+++ b/tests/admin_views/customadmin.py
@@ -60,8 +60,19 @@ class CustomPwdTemplateUserAdmin(UserAdmin):
class BookAdmin(admin.ModelAdmin):
+ delete_confirmation_max_display = 1
+
def get_deleted_objects(self, objs, request):
- return ["a deletable object"], {"books": 1}, set(), []
+ return (
+ ["a deletable object", "another object", "last object"],
+ {"books": 1},
+ set(),
+ [],
+ )
+
+
+class BookAdminZeroDisplay(BookAdmin):
+ delete_confirmation_max_display = 0
site = Admin2(name="admin2")
@@ -80,3 +91,6 @@ site.register(models.Simple, base_admin.AttributeErrorRaisingAdmin)
simple_site = Admin2(name="admin4")
simple_site.register(User, CustomPwdTemplateUserAdmin)
+
+zero_display_site = Admin2(name="admin_zero_display")
+zero_display_site.register(models.Book, BookAdminZeroDisplay)
diff --git a/tests/admin_views/test_actions.py b/tests/admin_views/test_actions.py
index c2c18e0b74..de5f5462a3 100644
--- a/tests/admin_views/test_actions.py
+++ b/tests/admin_views/test_actions.py
@@ -220,6 +220,19 @@ class AdminActionsTest(TestCase):
# BookAdmin.get_deleted_objects() returns custom text.
self.assertContains(response, "a deletable object")
+ def test_delete_selected_uses_delete_confirmation_max_display(self):
+ 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)
+ self.assertContains(response, "a deletable object")
+ self.assertContains(response, "…and 2 more objects.")
+ self.assertNotContains(response, "another object")
+ self.assertNotContains(response, "last 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 399c485fea..1fdb90822c 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -4198,6 +4198,26 @@ class AdminViewDeletedObjectsTest(TestCase):
# BookAdmin.get_deleted_objects() returns custom text.
self.assertContains(response, "a deletable object")
+ def test_delete_view_uses_delete_confirmation_max_display(self):
+ book = Book.objects.create(name="Test Book")
+ response = self.client.get(
+ reverse("admin2:admin_views_book_delete", args=(book.pk,))
+ )
+ self.assertContains(response, "a deletable object")
+ self.assertContains(response, "…and 2 more objects.")
+ self.assertNotContains(response, "another object")
+ self.assertNotContains(response, "last object")
+
+ def test_delete_view_hides_objects_when_delete_confirmation_max_display_is_zero(
+ self,
+ ):
+ book = Book.objects.create(name="Test Book")
+ response = self.client.get(
+ reverse("admin_zero_display:admin_views_book_delete", args=(book.pk,))
+ )
+ self.assertNotContains(response, "<h2>Objects</h2>")
+ self.assertNotContains(response, 'id="deleted-objects"')
+
@override_settings(ROOT_URLCONF="admin_views.urls")
class TestGenericRelations(TestCase):
diff --git a/tests/admin_views/urls.py b/tests/admin_views/urls.py
index 3c43b8721d..0ebe688cfc 100644
--- a/tests/admin_views/urls.py
+++ b/tests/admin_views/urls.py
@@ -35,6 +35,7 @@ urlpatterns = [
path("test_admin/admin11/", admin.site11.urls),
path("test_admin/admin12/", admin.site12.urls),
path("test_admin/admin13/", admin.site13.urls),
+ path("test_admin/admin_zero_display/", customadmin.zero_display_site.urls),
path("test_admin/has_permission_admin/", custom_has_permission_admin.site.urls),
path("test_admin/autocomplete_admin/", autocomplete_site.urls),
# Shares the admin URL prefix.
diff --git a/tests/modeladmin/test_checks.py b/tests/modeladmin/test_checks.py
index c493148eb9..496e46e8b0 100644
--- a/tests/modeladmin/test_checks.py
+++ b/tests/modeladmin/test_checks.py
@@ -1020,6 +1020,50 @@ class ListMaxShowAllCheckTests(CheckTestCase):
self.assertIsValid(TestModelAdmin, ValidationTestModel)
+class DeleteConfirmationMaxObjectsCheckTests(CheckTestCase):
+ def test_not_integer(self):
+ class TestModelAdmin(ModelAdmin):
+ delete_confirmation_max_display = "hello"
+
+ self.assertIsInvalid(
+ TestModelAdmin,
+ ValidationTestModel,
+ (
+ "The value of "
+ "'delete_confirmation_max_display'"
+ " must be a non-negative integer or None."
+ ),
+ "admin.E131",
+ )
+
+ def test_negative_integer(self):
+ class TestModelAdmin(ModelAdmin):
+ delete_confirmation_max_display = -1
+
+ self.assertIsInvalid(
+ TestModelAdmin,
+ ValidationTestModel,
+ (
+ "The value of "
+ "'delete_confirmation_max_display'"
+ " must be a non-negative integer or None."
+ ),
+ "admin.E131",
+ )
+
+ def test_valid_case(self):
+ class TestModelAdmin(ModelAdmin):
+ delete_confirmation_max_display = 100
+
+ self.assertIsValid(TestModelAdmin, ValidationTestModel)
+
+ def test_valid_none(self):
+ class TestModelAdmin(ModelAdmin):
+ delete_confirmation_max_display = None
+
+ self.assertIsValid(TestModelAdmin, ValidationTestModel)
+
+
class SearchFieldsCheckTests(CheckTestCase):
def test_not_iterable(self):
class TestModelAdmin(ModelAdmin):