diff options
| -rw-r--r-- | django/contrib/admin/actions.py | 1 | ||||
| -rw-r--r-- | django/contrib/admin/checks.py | 20 | ||||
| -rw-r--r-- | django/contrib/admin/options.py | 2 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/delete_confirmation.html | 8 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/delete_selected_confirmation.html | 8 | ||||
| -rw-r--r-- | django/contrib/admin/utils.py | 3 | ||||
| -rw-r--r-- | docs/ref/checks.txt | 2 | ||||
| -rw-r--r-- | docs/ref/contrib/admin/index.txt | 16 | ||||
| -rw-r--r-- | docs/releases/6.1.txt | 5 | ||||
| -rw-r--r-- | tests/admin_views/customadmin.py | 16 | ||||
| -rw-r--r-- | tests/admin_views/test_actions.py | 13 | ||||
| -rw-r--r-- | tests/admin_views/tests.py | 20 | ||||
| -rw-r--r-- | tests/admin_views/urls.py | 1 | ||||
| -rw-r--r-- | tests/modeladmin/test_checks.py | 44 |
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): |
