Skip to content

Commit a586f03

Browse files
rodbvjacobtylerwalls
authored andcommitted
Refs django#10919 -- Refactored walk_items as module-level _walk_items and added truncated_unordered_list filter.
1 parent 61a62be commit a586f03

4 files changed

Lines changed: 224 additions & 27 deletions

File tree

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from django import template
22
from django.contrib.admin.options import EMPTY_VALUE_STRING
33
from django.contrib.admin.utils import display_for_value
4-
from django.template.defaultfilters import stringfilter
4+
from django.template.defaultfilters import _walk_items, stringfilter
5+
from django.utils.html import conditional_escape
6+
from django.utils.safestring import mark_safe
7+
from django.utils.translation import ngettext
58

69
register = template.Library()
710

@@ -10,3 +13,67 @@
1013
@stringfilter
1114
def to_object_display_value(value):
1215
return display_for_value(str(value), EMPTY_VALUE_STRING)
16+
17+
18+
@register.filter(is_safe=True, needs_autoescape=True)
19+
def truncated_unordered_list(value, max_items, autoescape=True):
20+
"""
21+
Render an unordered list, showing at most ``max_items`` items and a
22+
"...and N more objects." item at the end.
23+
24+
Usage::
25+
26+
{{ deleted_objects|truncated_unordered_list:100 }}
27+
"""
28+
29+
has_unlimited_items = max_items is None
30+
if not has_unlimited_items:
31+
max_items = int(max_items)
32+
if max_items <= 0:
33+
return mark_safe("")
34+
35+
if autoescape:
36+
escaper = conditional_escape
37+
else:
38+
39+
def escaper(x):
40+
return x
41+
42+
item_count = 0
43+
44+
def list_formatter(item_list, tabs=1):
45+
nonlocal item_count
46+
indent = "\t" * tabs
47+
output = []
48+
for item, children in _walk_items(item_list):
49+
sublist = ""
50+
item_count += 1
51+
should_display_item = has_unlimited_items or 0 < item_count <= max_items
52+
if children:
53+
sublist = "\n%s<ul>\n%s\n%s</ul>\n%s" % (
54+
indent,
55+
list_formatter(children, tabs + 1),
56+
indent,
57+
indent,
58+
)
59+
60+
if should_display_item:
61+
output.append("%s<li>%s%s</li>" % (indent, escaper(item), sublist))
62+
63+
return "\n".join(output)
64+
65+
rendered_object_list = list_formatter(value)
66+
remaining_objects_message = ""
67+
68+
if not has_unlimited_items and item_count > max_items:
69+
remaining_object_count = item_count - max_items
70+
remaining_objects_message = "\n\t<li>%s</li>" % (
71+
ngettext(
72+
"…and %(count)d more object.",
73+
"…and %(count)d more objects.",
74+
remaining_object_count,
75+
)
76+
% {"count": remaining_object_count}
77+
)
78+
79+
return mark_safe(rendered_object_list + remaining_objects_message)

django/template/defaultfilters.py

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,31 @@ def slice_filter(value, arg):
666666
return value # Fail silently.
667667

668668

669+
def _walk_items(item_list):
670+
item_iterator = iter(item_list)
671+
try:
672+
item = next(item_iterator)
673+
while True:
674+
try:
675+
next_item = next(item_iterator)
676+
except StopIteration:
677+
yield item, None
678+
break
679+
if isinstance(next_item, (list, tuple, types.GeneratorType)):
680+
try:
681+
iter(next_item)
682+
except TypeError:
683+
pass
684+
else:
685+
yield item, next_item
686+
item = next(item_iterator)
687+
continue
688+
yield item, None
689+
item = next_item
690+
except StopIteration:
691+
pass
692+
693+
669694
@register.filter(is_safe=True, needs_autoescape=True)
670695
def unordered_list(value, autoescape=True):
671696
"""
@@ -695,34 +720,10 @@ def unordered_list(value, autoescape=True):
695720
def escaper(x):
696721
return x
697722

698-
def walk_items(item_list):
699-
item_iterator = iter(item_list)
700-
try:
701-
item = next(item_iterator)
702-
while True:
703-
try:
704-
next_item = next(item_iterator)
705-
except StopIteration:
706-
yield item, None
707-
break
708-
if isinstance(next_item, (list, tuple, types.GeneratorType)):
709-
try:
710-
iter(next_item)
711-
except TypeError:
712-
pass
713-
else:
714-
yield item, next_item
715-
item = next(item_iterator)
716-
continue
717-
yield item, None
718-
item = next_item
719-
except StopIteration:
720-
pass
721-
722723
def list_formatter(item_list, tabs=1):
723724
indent = "\t" * tabs
724725
output = []
725-
for item, children in walk_items(item_list):
726+
for item, children in _walk_items(item_list):
726727
sublist = ""
727728
if children:
728729
sublist = "\n%s<ul>\n%s\n%s</ul>\n%s" % (

tests/admin_views/test_templatetags.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import datetime
22
import unittest
33

4+
from django import template
45
from django.contrib.admin import ModelAdmin
6+
from django.contrib.admin.templatetags.admin_filters import truncated_unordered_list
57
from django.contrib.admin.templatetags.admin_list import date_hierarchy
68
from django.contrib.admin.templatetags.admin_modify import submit_row
79
from django.contrib.admin.templatetags.base import InclusionAdminNode
810
from django.contrib.auth import get_permission_codename
911
from django.contrib.auth.admin import UserAdmin
1012
from django.contrib.auth.models import User
1113
from django.template.base import Token, TokenType
12-
from django.test import RequestFactory, TestCase
14+
from django.test import RequestFactory, SimpleTestCase, TestCase
1315
from django.urls import reverse
1416
from django.utils.version import PY314
1517

@@ -18,6 +20,120 @@
1820
from .tests import AdminViewBasicTestCase, get_perm
1921

2022

23+
class TruncatedUnorderedListTests(SimpleTestCase):
24+
def test_no_max_items(self):
25+
result = truncated_unordered_list(["item 1", "item 2"], None)
26+
self.assertEqual(result, ("\t<li>item 1</li>\n" "\t<li>item 2</li>"))
27+
self.assertNotIn("more object", result)
28+
29+
def test_max_items_zero(self):
30+
result = truncated_unordered_list(["a", "b", "c"], 0)
31+
self.assertEqual(result, "")
32+
self.assertNotIn("more object", result)
33+
34+
def test_flat_list_truncated(self):
35+
self.assertEqual(
36+
truncated_unordered_list(["a", "b", "c", "d", "e"], 3),
37+
(
38+
"\t<li>a</li>\n"
39+
"\t<li>b</li>\n"
40+
"\t<li>c</li>\n"
41+
"\t<li>…and 2 more objects.</li>"
42+
),
43+
)
44+
45+
def test_nested_two_levels_truncated(self):
46+
self.assertEqual(
47+
truncated_unordered_list(["a", ["a1", "a2"], "b", "c", "d", "e"], 3),
48+
(
49+
"\t<li>a\n"
50+
"\t<ul>\n"
51+
"\t\t<li>a1</li>\n"
52+
"\t\t<li>a2</li>\n"
53+
"\t</ul>\n"
54+
"\t</li>\n"
55+
"\t<li>…and 4 more objects.</li>"
56+
),
57+
)
58+
59+
def test_nested_and_top_level_truncated(self):
60+
self.assertEqual(
61+
truncated_unordered_list(
62+
["a", ["n1", "n2", "n3", "n4", "n5"], "b", "c", "d", "e"],
63+
3,
64+
),
65+
(
66+
"\t<li>a\n"
67+
"\t<ul>\n"
68+
"\t\t<li>n1</li>\n"
69+
"\t\t<li>n2</li>\n"
70+
"\t</ul>\n"
71+
"\t</li>\n"
72+
"\t<li>…and 7 more objects.</li>"
73+
),
74+
)
75+
76+
def test_nested_three_levels_truncated(self):
77+
self.assertEqual(
78+
truncated_unordered_list(["a", ["a1", ["a1x"]], "b", "c", "d", "e"], 3),
79+
(
80+
"\t<li>a\n"
81+
"\t<ul>\n"
82+
"\t\t<li>a1\n"
83+
"\t\t<ul>\n"
84+
"\t\t\t<li>a1x</li>\n"
85+
"\t\t</ul>\n"
86+
"\t\t</li>\n"
87+
"\t</ul>\n"
88+
"\t</li>\n"
89+
"\t<li>…and 4 more objects.</li>"
90+
),
91+
)
92+
93+
def test_max_items_equal_to_length(self):
94+
self.assertEqual(
95+
truncated_unordered_list(["a", "b", "c"], 3),
96+
("\t<li>a</li>\n" "\t<li>b</li>\n" "\t<li>c</li>"),
97+
)
98+
99+
def test_max_items_greater_than_length(self):
100+
self.assertEqual(
101+
truncated_unordered_list(["a", "b"], 10),
102+
("\t<li>a</li>\n" "\t<li>b</li>"),
103+
)
104+
105+
def test_truncated_single_remaining(self):
106+
self.assertEqual(
107+
truncated_unordered_list(["a", "b", "c"], 2),
108+
("\t<li>a</li>\n" "\t<li>b</li>\n" "\t<li>…and 1 more object.</li>"),
109+
)
110+
111+
def test_autoescape(self):
112+
self.assertEqual(
113+
truncated_unordered_list(["<a>item</a>", "safe"], 1),
114+
("\t<li>&lt;a&gt;item&lt;/a&gt;</li>\n" "\t<li>…and 1 more object.</li>"),
115+
)
116+
117+
def test_autoescape_off(self):
118+
self.assertEqual(
119+
truncated_unordered_list(["<a>item</a>", "safe"], 1, autoescape=False),
120+
("\t<li><a>item</a></li>\n" "\t<li>…and 1 more object.</li>"),
121+
)
122+
123+
def test_empty_list(self):
124+
self.assertEqual(truncated_unordered_list([], 5), "")
125+
126+
def test_template_rendering(self):
127+
t = template.Template(
128+
"{% load admin_filters %}{{ items|truncated_unordered_list:2 }}"
129+
)
130+
result = t.render(template.Context({"items": ["a", "b", "c"]}))
131+
self.assertIn("<li>a</li>", result)
132+
self.assertIn("<li>b</li>", result)
133+
self.assertIn("<li>…and 1 more object.</li>", result)
134+
self.assertNotIn("<li>c</li>", result)
135+
136+
21137
class AdminTemplateTagsTest(AdminViewBasicTestCase):
22138
request_factory = RequestFactory()
23139

tests/template_tests/filter_tests/test_unordered_list.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,19 @@ def item_generator():
160160
"<li>D</li>",
161161
)
162162

163+
def test_non_iterable_list_subclass(self):
164+
class NonIterableList(list):
165+
def __iter__(self):
166+
raise TypeError
167+
168+
def __str__(self):
169+
return "non-iterable-list"
170+
171+
self.assertEqual(
172+
unordered_list(["A", NonIterableList(["x"]), "B"]),
173+
"\t<li>A</li>\n\t<li>non-iterable-list</li>\n\t<li>B</li>",
174+
)
175+
163176
def test_ulitem_autoescape_off(self):
164177
class ULItem:
165178
def __init__(self, title):

0 commit comments

Comments
 (0)