diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py index 04a906542a00..a2b92e492dbc 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 d14515e8a424..7baaa38a29f0 100644 --- a/django/contrib/admin/checks.py +++ b/django/contrib/admin/checks.py @@ -842,6 +842,7 @@ def check(self, admin_obj, **kwargs): *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 @@ def _check_list_select_related(self, obj): 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 71d4a2d55ca5..e5502c42d583 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 @@ def _delete_view(self, request, object_id, extra_context): "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 07823db373f0..1c34a84d953a 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 %}

{% 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 %}

- + {% endblock %} {% elif protected %} {% block delete_protected %}

{% blocktranslate %}Deleting the {{ object_name }} “{{ escaped_object }}” would require deleting the following protected related objects:{% endblocktranslate %}

- + {% endblock %} {% else %} {% block delete_confirm %}

{% blocktranslate %}Are you sure you want to delete the {{ object_name }} “{{ escaped_object }}”? All of the following related items will be deleted:{% endblocktranslate %}

{% include "admin/includes/object_delete_summary.html" %} + {% if delete_confirmation_max_display is None or delete_confirmation_max_display > 0 %}

{% translate "Objects" %}

- + + {% endif %}
{% csrf_token %}
diff --git a/django/contrib/admin/templates/admin/delete_selected_confirmation.html b/django/contrib/admin/templates/admin/delete_selected_confirmation.html index c6d0566883ae..40cfdbcc4cd2 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 %}

{% 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 %}

- + {% elif protected %}

{% blocktranslate %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktranslate %}

- + {% else %}

{% 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 %}

{% include "admin/includes/object_delete_summary.html" %}

{% translate "Objects" %}

{% for deletable_object in deletable_objects %} - + {% endfor %} {% csrf_token %}
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\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\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, ("\t
  • item 1
  • \n" "\t
  • item 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), + ( + "\t
  • a
  • \n" + "\t
  • b
  • \n" + "\t
  • c
  • \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), + ( + "\t
  • a\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, + ), + ( + "\t
  • a\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), + ( + "\t
  • a\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), + ("\t
  • a
  • \n" "\t
  • b
  • \n" "\t
  • c
  • "), + ) + + def test_max_items_greater_than_length(self): + self.assertEqual( + truncated_unordered_list(["a", "b"], 10), + ("\t
  • a
  • \n" "\t
  • b
  • "), + ) + + def test_truncated_single_remaining(self): + self.assertEqual( + truncated_unordered_list(["a", "b", "c"], 2), + ("\t
  • a
  • \n" "\t
  • b
  • \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), + ("\t
  • item
  • \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"]), + "\t
  • A
  • \n\t
  • non-iterable-list
  • \n\t
  • B
  • ", + ) + def test_ulitem_autoescape_off(self): class ULItem: def __init__(self, title):