diff --git a/django/contrib/admin/templatetags/admin_filters.py b/django/contrib/admin/templatetags/admin_filters.py
index d0b17970a696..8c98a32c2943 100644
--- a/django/contrib/admin/templatetags/admin_filters.py
+++ b/django/contrib/admin/templatetags/admin_filters.py
@@ -1,7 +1,10 @@
from django import template
from django.contrib.admin.options import EMPTY_VALUE_STRING
from django.contrib.admin.utils import display_for_value
-from django.template.defaultfilters import stringfilter
+from django.template.defaultfilters import _walk_items, stringfilter
+from django.utils.html import conditional_escape
+from django.utils.safestring import mark_safe
+from django.utils.translation import ngettext
register = template.Library()
@@ -10,3 +13,67 @@
@stringfilter
def to_object_display_value(value):
return display_for_value(str(value), EMPTY_VALUE_STRING)
+
+
+@register.filter(is_safe=True, needs_autoescape=True)
+def truncated_unordered_list(value, max_items, autoescape=True):
+ """
+ Render an unordered list, showing at most ``max_items`` items and a
+ "...and N more objects." item at the end.
+
+ Usage::
+
+ {{ deleted_objects|truncated_unordered_list:100 }}
+ """
+
+ has_unlimited_items = max_items is None
+ if not has_unlimited_items:
+ max_items = int(max_items)
+ if max_items <= 0:
+ return mark_safe("")
+
+ if autoescape:
+ escaper = conditional_escape
+ else:
+
+ def escaper(x):
+ return x
+
+ item_count = 0
+
+ def list_formatter(item_list, tabs=1):
+ nonlocal item_count
+ indent = "\t" * tabs
+ output = []
+ for item, children in _walk_items(item_list):
+ sublist = ""
+ item_count += 1
+ should_display_item = has_unlimited_items or 0 < item_count <= max_items
+ if children:
+ sublist = "\n%s
\n%s" % (
+ indent,
+ list_formatter(children, tabs + 1),
+ indent,
+ indent,
+ )
+
+ if should_display_item:
+ output.append("%s
%s%s" % (indent, escaper(item), sublist))
+
+ return "\n".join(output)
+
+ rendered_object_list = list_formatter(value)
+ remaining_objects_message = ""
+
+ if not has_unlimited_items and item_count > max_items:
+ remaining_object_count = item_count - max_items
+ remaining_objects_message = "\n\t
%s" % (
+ ngettext(
+ "…and %(count)d more object.",
+ "…and %(count)d more objects.",
+ remaining_object_count,
+ )
+ % {"count": remaining_object_count}
+ )
+
+ return mark_safe(rendered_object_list + remaining_objects_message)
diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py
index 3d5b023dea64..ed6c41f52eb4 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/django/template/defaultfilters.py b/django/template/defaultfilters.py
index 3ed9856c0834..68641062a6b8 100644
--- a/django/template/defaultfilters.py
+++ b/django/template/defaultfilters.py
@@ -666,6 +666,31 @@ def slice_filter(value, arg):
return value # Fail silently.
+def _walk_items(item_list):
+ item_iterator = iter(item_list)
+ try:
+ item = next(item_iterator)
+ while True:
+ try:
+ next_item = next(item_iterator)
+ except StopIteration:
+ yield item, None
+ break
+ if isinstance(next_item, (list, tuple, types.GeneratorType)):
+ try:
+ iter(next_item)
+ except TypeError:
+ pass
+ else:
+ yield item, next_item
+ item = next(item_iterator)
+ continue
+ yield item, None
+ item = next_item
+ except StopIteration:
+ pass
+
+
@register.filter(is_safe=True, needs_autoescape=True)
def unordered_list(value, autoescape=True):
"""
@@ -695,34 +720,10 @@ def unordered_list(value, autoescape=True):
def escaper(x):
return x
- def walk_items(item_list):
- item_iterator = iter(item_list)
- try:
- item = next(item_iterator)
- while True:
- try:
- next_item = next(item_iterator)
- except StopIteration:
- yield item, None
- break
- if isinstance(next_item, (list, tuple, types.GeneratorType)):
- try:
- iter(next_item)
- except TypeError:
- pass
- else:
- yield item, next_item
- item = next(item_iterator)
- continue
- yield item, None
- item = next_item
- except StopIteration:
- pass
-
def list_formatter(item_list, tabs=1):
indent = "\t" * tabs
output = []
- for item, children in walk_items(item_list):
+ for item, children in _walk_items(item_list):
sublist = ""
if children:
sublist = "\n%s
\n%s" % (
diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt
index fb9187c2ac9e..5b4e31420bc6 100644
--- a/docs/ref/checks.txt
+++ b/docs/ref/checks.txt
@@ -816,6 +816,8 @@ with the admin site:
method for the ``
`` action.
* **admin.E130**: ``__name__`` attributes of actions defined in
```` must be unique. 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 b3c7c2c42699..4b0e4a985de4 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`.
+.. 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 a4e10d2102d2..48f893904140 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -99,6 +99,11 @@ Minor features
preserve :ref:`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 a63d24a9ee06..9621cb4d5246 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 @@ def get_deleted_objects(self, objs, request):
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 c2c18e0b743e..de5f5462a344 100644
--- a/tests/admin_views/test_actions.py
+++ b/tests/admin_views/test_actions.py
@@ -220,6 +220,19 @@ def test_delete_selected_uses_get_deleted_objects(self):
# 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/test_templatetags.py b/tests/admin_views/test_templatetags.py
index 69bae2741240..519bd14954ae 100644
--- a/tests/admin_views/test_templatetags.py
+++ b/tests/admin_views/test_templatetags.py
@@ -1,7 +1,9 @@
import datetime
import unittest
+from django import template
from django.contrib.admin import ModelAdmin
+from django.contrib.admin.templatetags.admin_filters import truncated_unordered_list
from django.contrib.admin.templatetags.admin_list import date_hierarchy
from django.contrib.admin.templatetags.admin_modify import submit_row
from django.contrib.admin.templatetags.base import InclusionAdminNode
@@ -9,7 +11,7 @@
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from django.template.base import Token, TokenType
-from django.test import RequestFactory, TestCase
+from django.test import RequestFactory, SimpleTestCase, TestCase
from django.urls import reverse
from django.utils.version import PY314
@@ -18,6 +20,120 @@
from .tests import AdminViewBasicTestCase, get_perm
+class TruncatedUnorderedListTests(SimpleTestCase):
+ def test_no_max_items(self):
+ result = truncated_unordered_list(["item 1", "item 2"], None)
+ self.assertEqual(result, ("\titem 1\n" "\titem 2"))
+ self.assertNotIn("more object", result)
+
+ def test_max_items_zero(self):
+ result = truncated_unordered_list(["a", "b", "c"], 0)
+ self.assertEqual(result, "")
+ self.assertNotIn("more object", result)
+
+ def test_flat_list_truncated(self):
+ self.assertEqual(
+ truncated_unordered_list(["a", "b", "c", "d", "e"], 3),
+ (
+ "\ta\n"
+ "\tb\n"
+ "\tc\n"
+ "\t…and 2 more objects."
+ ),
+ )
+
+ def test_nested_two_levels_truncated(self):
+ self.assertEqual(
+ truncated_unordered_list(["a", ["a1", "a2"], "b", "c", "d", "e"], 3),
+ (
+ "\ta\n"
+ "\t\n"
+ "\t\t- a1
\n"
+ "\t\t- a2
\n"
+ "\t
\n"
+ "\t\n"
+ "\t…and 4 more objects."
+ ),
+ )
+
+ def test_nested_and_top_level_truncated(self):
+ self.assertEqual(
+ truncated_unordered_list(
+ ["a", ["n1", "n2", "n3", "n4", "n5"], "b", "c", "d", "e"],
+ 3,
+ ),
+ (
+ "\ta\n"
+ "\t\n"
+ "\t\t- n1
\n"
+ "\t\t- n2
\n"
+ "\t
\n"
+ "\t\n"
+ "\t…and 7 more objects."
+ ),
+ )
+
+ def test_nested_three_levels_truncated(self):
+ self.assertEqual(
+ truncated_unordered_list(["a", ["a1", ["a1x"]], "b", "c", "d", "e"], 3),
+ (
+ "\ta\n"
+ "\t\n"
+ "\t\t- a1\n"
+ "\t\t
\n"
+ "\t\t\t- a1x
\n"
+ "\t\t
\n"
+ "\t\t \n"
+ "\t
\n"
+ "\t\n"
+ "\t…and 4 more objects."
+ ),
+ )
+
+ def test_max_items_equal_to_length(self):
+ self.assertEqual(
+ truncated_unordered_list(["a", "b", "c"], 3),
+ ("\ta\n" "\tb\n" "\tc"),
+ )
+
+ def test_max_items_greater_than_length(self):
+ self.assertEqual(
+ truncated_unordered_list(["a", "b"], 10),
+ ("\ta\n" "\tb"),
+ )
+
+ def test_truncated_single_remaining(self):
+ self.assertEqual(
+ truncated_unordered_list(["a", "b", "c"], 2),
+ ("\ta\n" "\tb\n" "\t…and 1 more object."),
+ )
+
+ def test_autoescape(self):
+ self.assertEqual(
+ truncated_unordered_list(["item", "safe"], 1),
+ ("\t<a>item</a>\n" "\t…and 1 more object."),
+ )
+
+ def test_autoescape_off(self):
+ self.assertEqual(
+ truncated_unordered_list(["item", "safe"], 1, autoescape=False),
+ ("\titem\n" "\t…and 1 more object."),
+ )
+
+ def test_empty_list(self):
+ self.assertEqual(truncated_unordered_list([], 5), "")
+
+ def test_template_rendering(self):
+ t = template.Template(
+ "{% load admin_filters %}{{ items|truncated_unordered_list:2 }}"
+ )
+ result = t.render(template.Context({"items": ["a", "b", "c"]}))
+ self.assertIn("a", result)
+ self.assertIn("b", result)
+ self.assertIn("…and 1 more object.", result)
+ self.assertNotIn("c", result)
+
+
class AdminTemplateTagsTest(AdminViewBasicTestCase):
request_factory = RequestFactory()
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 399c485fea10..1fdb90822ce3 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -4198,6 +4198,26 @@ def test_delete_view_uses_get_deleted_objects(self):
# 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, "Objects
")
+ 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 3c43b8721dc4..0ebe688cfc19 100644
--- a/tests/admin_views/urls.py
+++ b/tests/admin_views/urls.py
@@ -35,6 +35,7 @@ def non_admin_view(request):
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 c493148eb9cf..496e46e8b01a 100644
--- a/tests/modeladmin/test_checks.py
+++ b/tests/modeladmin/test_checks.py
@@ -1020,6 +1020,50 @@ class TestModelAdmin(ModelAdmin):
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):
diff --git a/tests/template_tests/filter_tests/test_unordered_list.py b/tests/template_tests/filter_tests/test_unordered_list.py
index 1748a0fb5474..ddbaedcc702e 100644
--- a/tests/template_tests/filter_tests/test_unordered_list.py
+++ b/tests/template_tests/filter_tests/test_unordered_list.py
@@ -160,6 +160,19 @@ def item_generator():
"D",
)
+ def test_non_iterable_list_subclass(self):
+ class NonIterableList(list):
+ def __iter__(self):
+ raise TypeError
+
+ def __str__(self):
+ return "non-iterable-list"
+
+ self.assertEqual(
+ unordered_list(["A", NonIterableList(["x"]), "B"]),
+ "\tA\n\tnon-iterable-list\n\tB",
+ )
+
def test_ulitem_autoescape_off(self):
class ULItem:
def __init__(self, title):