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 AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ answer newbie questions, and generally made Django that much better:
Karderio <karderio@gmail.com>
Karen Tracey <kmtracey@gmail.com>
Karol Sikora <elektrrrus@gmail.com>
Karolis Ryselis <karolis.ryselis@gmail.com>
Kasey Steinhauer <kstein257@gmail.com>
Kasun Herath <kasunh01@gmail.com>
Katherine “Kati” Michel <kthrnmichel@gmail.com>
Expand Down
40 changes: 20 additions & 20 deletions django/contrib/admin/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ def check(self, admin_obj, **kwargs):
*self._check_view_on_site_url(admin_obj),
*self._check_ordering(admin_obj),
*self._check_readonly_fields(admin_obj),
*self._check_delete_confirmation_max_display(admin_obj),
]

def _check_autocomplete_fields(self, obj):
Expand Down Expand Up @@ -824,6 +825,25 @@ def _check_readonly_fields_item(self, obj, field_name, label):
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.E041",
)
else:
return []


class ModelAdminChecks(BaseModelAdminChecks):
def check(self, admin_obj, **kwargs):
Expand All @@ -842,7 +862,6 @@ 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 @@ -1093,25 +1112,6 @@ 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
34 changes: 31 additions & 3 deletions django/contrib/admin/options.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import copy
import enum
import itertools
import json
import re
import sys
import warnings
from collections.abc import Callable
from dataclasses import dataclass
Expand Down Expand Up @@ -191,6 +193,7 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
view_on_site = True
show_full_result_count = True
checks_class = BaseModelAdminChecks
delete_confirmation_max_display = None

def check(self, **kwargs):
return self.checks_class().check(self, **kwargs)
Expand Down Expand Up @@ -704,7 +707,6 @@ 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 @@ -2755,6 +2757,11 @@ def get_formset(self, request, obj=None, **kwargs):
base_model_form = defaults["form"]
can_change = self.has_change_permission(request, obj) if request else True
can_add = self.has_add_permission(request, obj) if request else True
delete_confirmation_max_display = (
self.delete_confirmation_max_display
if self.delete_confirmation_max_display
else sys.maxsize
)

class DeleteProtectedModelForm(base_model_form):
def hand_clean_DELETE(self):
Expand All @@ -2771,7 +2778,10 @@ def hand_clean_DELETE(self):
collector.collect([self.instance])
if collector.protected:
objs = []
for p in collector.protected:
protected = itertools.islice(
collector.protected, delete_confirmation_max_display
)
for p in protected:
objs.append(
# Translators: Model verbose name and instance
# representation, suitable to be an item in a
Expand All @@ -2782,8 +2792,26 @@ def hand_clean_DELETE(self):
params = {
"class_name": self._meta.model._meta.verbose_name,
"instance": self.instance,
"related_objects": get_text_list(objs, _("and")),
}
remaining_object_count = (
len(collector.protected) - delete_confirmation_max_display
)
if remaining_object_count > 0:
# Translators: This string is used as a separator
# between list elements
related = (
_(", ").join(str(i) for i in objs)
+ _(", ")
+ ngettext(
"…and %(count)d more object.",
"…and %(count)d more objects.",
remaining_object_count,
)
% {"count": remaining_object_count}
)
else:
related = get_text_list(objs, _("and"))
params["related_objects"] = related
msg = _(
"Deleting %(class_name)s %(instance)s would require "
"deleting the following protected related objects: "
Expand Down
4 changes: 2 additions & 2 deletions docs/ref/checks.txt
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,8 @@ with the admin site:
referenced by ``<modeladmin>.autocomplete_fields``.
* **admin.E040**: ``<modeladmin>`` must define ``search_fields``, because
it's referenced by ``<other_modeladmin>.autocomplete_fields``.
* **admin.E041**: The value of ``delete_confirmation_max_display`` must be a
non-negative integer or ``None``.

``ModelAdmin``
~~~~~~~~~~~~~~
Expand Down Expand Up @@ -819,8 +821,6 @@ 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
6 changes: 4 additions & 2 deletions docs/ref/contrib/admin/index.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1403,8 +1403,10 @@ default templates used by the :class:`ModelAdmin` views:
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).
This applies to :meth:`delete_view` and the ``delete_selected``
action if specified for ``ModelAdmin``, and to protected deletion
validation messages if specified for ``InlineModelAdmin``. By default,
this is ``None`` (no truncation).

.. attribute:: ModelAdmin.object_history_template

Expand Down
4 changes: 2 additions & 2 deletions docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ Minor features

* 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).
confirmation pages and inline protected deletion errors before the remainder
is truncated. The default is ``None`` (no truncation).

* In order to improve accessibility of the admin change forms:

Expand Down
1 change: 1 addition & 0 deletions tests/admin_inlines/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ def call_me(self, obj):
class ChapterInline(admin.TabularInline):
model = Chapter
readonly_fields = ["call_me"]
delete_confirmation_max_display = 3

def call_me(self, obj):
return "Callable in ChapterInline"
Expand Down
4 changes: 1 addition & 3 deletions tests/admin_inlines/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,7 @@ class Chapter(models.Model):


class FootNote(models.Model):
"""
Model added for ticket 19838
"""
"""Model for models.PROTECT."""

chapter = models.ForeignKey(Chapter, models.PROTECT)
note = models.CharField(max_length=40)
Expand Down
70 changes: 70 additions & 0 deletions tests/admin_inlines/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,76 @@ def test_inlines_based_on_model_state(self):
parent.refresh_from_db()
self.assertIs(parent.show_inlines, True)

def test_delete_protected_message_limits_number_of_objects_displayed(self):
# admin limits the number of displayed objects to 2, so we create
# 5 footnotes.
novel = Novel.objects.create()
chapter = Chapter.objects.create(novel=novel)
footnotes = [FootNote(chapter=chapter) for i in range(5)]
FootNote.objects.bulk_create(footnotes)

response = self.client.post(
reverse("admin:admin_inlines_novel_change", args=(novel.pk,)),
data={
"show_inlines": "on",
"chapter_set-TOTAL_FORMS": "1",
"chapter_set-INITIAL_FORMS": "1",
"chapter_set-MAX_NUM_FORMS": "1000",
"chapter_set-MIN_NUM_FORMS": "0",
"chapter_set-0-id": chapter.id,
"chapter_set-0-name": chapter.name,
"chapter_set-0-novel": novel.id,
"chapter_set-0-DELETE": "on",
},
)
self.assertEqual(response.status_code, 200)
inline_formset = response.context_data["inline_admin_formsets"][0]
self.assertEqual(1, len(inline_formset.non_form_errors()))
error_message = inline_formset.non_form_errors()[0]
self.assertTrue(
error_message.startswith(
f"Deleting chapter Chapter object ({chapter.pk}) would "
"require deleting the following protected related objects:"
),
error_message,
)
self.assertEqual(error_message.count("FootNote"), 3, error_message)
self.assertTrue(error_message.endswith("…and 2 more objects."), error_message)

def test_delete_protected_message_does_not_limit_small_amount_of_objects(self):
novel = Novel.objects.create()
chapter = Chapter.objects.create(novel=novel)
footnotes = [FootNote(chapter=chapter) for i in range(3)]
FootNote.objects.bulk_create(footnotes)

response = self.client.post(
reverse("admin:admin_inlines_novel_change", args=(novel.pk,)),
data={
"show_inlines": "on",
"chapter_set-TOTAL_FORMS": "1",
"chapter_set-INITIAL_FORMS": "1",
"chapter_set-MAX_NUM_FORMS": "1000",
"chapter_set-MIN_NUM_FORMS": "0",
"chapter_set-0-id": chapter.id,
"chapter_set-0-name": chapter.name,
"chapter_set-0-novel": novel.id,
"chapter_set-0-DELETE": "on",
},
)
self.assertEqual(response.status_code, 200)
inline_formset = response.context_data["inline_admin_formsets"][0]
self.assertEqual(1, len(inline_formset.non_form_errors()))
error_message = inline_formset.non_form_errors()[0]
self.assertTrue(
error_message.startswith(
f"Deleting chapter Chapter object ({chapter.pk}) would require "
"deleting the following protected related objects:"
),
error_message,
)
self.assertEqual(error_message.count("FootNote object"), 3)
self.assertNotIn("more", error_message)


@override_settings(ROOT_URLCONF="admin_inlines.urls")
class TestInlineMedia(TestDataMixin, TestCase):
Expand Down
20 changes: 18 additions & 2 deletions tests/modeladmin/test_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1033,7 +1033,7 @@ class TestModelAdmin(ModelAdmin):
"'delete_confirmation_max_display'"
" must be a non-negative integer or None."
),
"admin.E131",
"admin.E041",
)

def test_negative_integer(self):
Expand All @@ -1048,7 +1048,7 @@ class TestModelAdmin(ModelAdmin):
"'delete_confirmation_max_display'"
" must be a non-negative integer or None."
),
"admin.E131",
"admin.E041",
)

def test_valid_case(self):
Expand All @@ -1063,6 +1063,22 @@ class TestModelAdmin(ModelAdmin):

self.assertIsValid(TestModelAdmin, ValidationTestModel)

def test_inline_not_integer(self):
class TestInlineModelAdmin(TabularInline):
delete_confirmation_max_display = "goodbye"
model = ValidationTestInlineModel

self.assertIsInvalid(
TestInlineModelAdmin,
ValidationTestModel,
(
"The value of "
"'delete_confirmation_max_display'"
" must be a non-negative integer or None."
),
"admin.E041",
)


class SearchFieldsCheckTests(CheckTestCase):
def test_not_iterable(self):
Expand Down
Loading