From a586f03f36f511064f171c0e30f4ca2ebfd60085 Mon Sep 17 00:00:00 2001 From: Rodrigo Vieira Date: Wed, 22 Apr 2026 18:53:13 -0300 Subject: Refs #10919 -- Refactored walk_items as module-level _walk_items and added truncated_unordered_list filter. --- django/contrib/admin/templatetags/admin_filters.py | 69 +++++++++++- django/template/defaultfilters.py | 51 ++++----- tests/admin_views/test_templatetags.py | 118 ++++++++++++++++++++- .../filter_tests/test_unordered_list.py | 13 +++ 4 files changed, 224 insertions(+), 27 deletions(-) diff --git a/django/contrib/admin/templatetags/admin_filters.py b/django/contrib/admin/templatetags/admin_filters.py index d0b17970a6..8c98a32c29 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 @@ register = template.Library() @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/template/defaultfilters.py b/django/template/defaultfilters.py index 3ed9856c08..68641062a6 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/tests/admin_views/test_templatetags.py b/tests/admin_views/test_templatetags.py index 69bae27412..519bd14954 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 import get_permission_codename 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 .models import Article, Question 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
  • \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
  • \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
  • \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/template_tests/filter_tests/test_unordered_list.py b/tests/template_tests/filter_tests/test_unordered_list.py index 1748a0fb54..ddbaedcc70 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 @@ class FunctionTests(SimpleTestCase): "
  • 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): -- cgit v1.3