Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions django/contrib/admin/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions django/contrib/admin/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions django/contrib/admin/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions django/contrib/admin/templates/admin/delete_confirmation.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }}
Expand All @@ -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>
Expand Down
69 changes: 68 additions & 1 deletion django/contrib/admin/templatetags/admin_filters.py
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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<ul>\n%s\n%s</ul>\n%s" % (
indent,
list_formatter(children, tabs + 1),
indent,
indent,
)

if should_display_item:
output.append("%s<li>%s%s</li>" % (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<li>%s</li>" % (
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)
3 changes: 2 additions & 1 deletion django/contrib/admin/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 26 additions & 25 deletions django/template/defaultfilters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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<ul>\n%s\n%s</ul>\n%s" % (
Expand Down
2 changes: 2 additions & 0 deletions docs/ref/checks.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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``
~~~~~~~~~~~~~~~~~~~~
Expand Down
16 changes: 16 additions & 0 deletions docs/ref/contrib/admin/index.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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`
Expand Down
5 changes: 5 additions & 0 deletions docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion tests/admin_views/customadmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
13 changes: 13 additions & 0 deletions tests/admin_views/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading