Skip to content

Commit 38ec0c2

Browse files
committed
Improved admin obj diff for long strings
They're now displayed using `template_utils.ObjDiffDisplay.common_shorten_repr()`.
1 parent fd72e7a commit 38ec0c2

File tree

4 files changed

+474
-54
lines changed

4 files changed

+474
-54
lines changed

simple_history/template_utils.py

Lines changed: 85 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,33 @@
33
from typing import Any, Dict, Final, List, Tuple, Type, Union
44

55
from django.db.models import ManyToManyField, Model
6-
from django.template.defaultfilters import truncatechars_html
76
from django.utils.html import conditional_escape
7+
from django.utils.safestring import SafeString, mark_safe
88
from django.utils.text import capfirst
99

1010
from .models import HistoricalChanges, ModelChange, ModelDelta, PKOrRelatedObj
1111
from .utils import get_m2m_reverse_field_name
1212

1313

14+
def conditional_str(obj: Any) -> str:
15+
"""
16+
Converts ``obj`` to a string, unless it's already one.
17+
"""
18+
if isinstance(obj, str):
19+
return obj
20+
return str(obj)
21+
22+
23+
def is_safe_str(s: Any) -> bool:
24+
"""
25+
Returns whether ``s`` is a (presumably) pre-escaped string or not.
26+
27+
This relies on the same ``__html__`` convention as Django's ``conditional_escape``
28+
does.
29+
"""
30+
return hasattr(s, "__html__")
31+
32+
1433
class HistoricalRecordContextHelper:
1534
"""
1635
Class containing various utilities for formatting the template context for
@@ -58,17 +77,17 @@ def format_delta_change(self, change: ModelChange) -> ModelChange:
5877
Return a ``ModelChange`` object with fields formatted for being used as
5978
template context.
6079
"""
80+
old = self.prepare_delta_change_value(change, change.old)
81+
new = self.prepare_delta_change_value(change, change.new)
6182

62-
def format_value(value):
63-
value = self.prepare_delta_change_value(change, value)
64-
return self.stringify_delta_change_value(change, value)
83+
old, new = self.stringify_delta_change_values(change, old, new)
6584

6685
field_meta = self.model._meta.get_field(change.field)
6786
return dataclasses.replace(
6887
change,
6988
field=capfirst(field_meta.verbose_name),
70-
old=format_value(change.old),
71-
new=format_value(change.new),
89+
old=old,
90+
new=new,
7291
)
7392

7493
def prepare_delta_change_value(
@@ -78,12 +97,11 @@ def prepare_delta_change_value(
7897
) -> Any:
7998
"""
8099
Return the prepared value for the ``old`` and ``new`` fields of ``change``,
81-
before it's passed through ``stringify_delta_change_value()`` (in
100+
before it's passed through ``stringify_delta_change_values()`` (in
82101
``format_delta_change()``).
83102
84103
For example, if ``value`` is a list of M2M related objects, it could be
85-
"prepared" by replacing the related objects with custom string representations,
86-
or by returning a more nicely formatted HTML string.
104+
"prepared" by replacing the related objects with custom string representations.
87105
88106
:param change:
89107
:param value: Either ``change.old`` or ``change.new``.
@@ -99,23 +117,46 @@ def prepare_delta_change_value(
99117
display_value = value
100118
return display_value
101119

102-
def stringify_delta_change_value(self, change: ModelChange, value: Any) -> str:
120+
def stringify_delta_change_values(
121+
self, change: ModelChange, old: Any, new: Any
122+
) -> Tuple[SafeString, SafeString]:
103123
"""
104-
Return the displayed value for the ``old`` and ``new`` fields of ``change``,
105-
after it's prepared by ``prepare_delta_change_value()``.
124+
Called by ``format_delta_change()`` after ``old`` and ``new`` have been
125+
prepared by ``prepare_delta_change_value()``.
106126
107-
:param change:
108-
:param value: Either ``change.old`` or ``change.new``, as returned by
109-
``prepare_delta_change_value()``.
127+
Return a tuple -- ``(old, new)`` -- where each element has been
128+
escaped/sanitized and turned into strings, ready to be displayed in a template.
129+
These can be HTML strings (remember to pass them through ``mark_safe()`` *after*
130+
escaping).
110131
"""
111-
# If `value` is a list, stringify it using `str()` instead of `repr()`
112-
# (the latter of which is the default when stringifying lists)
113-
if isinstance(value, list):
114-
value = f'[{", ".join(map(str, value))}]'
115132

116-
value = conditional_escape(value)
117-
value = truncatechars_html(value, self.max_displayed_delta_change_chars)
118-
return value
133+
def stringify_value(value) -> Union[str, SafeString]:
134+
# If `value` is a list, stringify each element using `str()` instead of
135+
# `repr()` (the latter is the default when calling `list.__str__()`)
136+
if isinstance(value, list):
137+
string = f"[{', '.join(map(conditional_str, value))}]"
138+
# If all elements are safe strings, reapply `mark_safe()`
139+
if all(map(is_safe_str, value)):
140+
string = mark_safe(string) # nosec
141+
else:
142+
string = conditional_str(value)
143+
return string
144+
145+
old_str, new_str = stringify_value(old), stringify_value(new)
146+
diff_display = self.get_obj_diff_display()
147+
old_short, new_short = diff_display.common_shorten_repr(old_str, new_str)
148+
# Escape *after* shortening, as any shortened, previously safe HTML strings have
149+
# likely been mangled. Other strings that have not been shortened, should have
150+
# their "safeness" unchanged
151+
return conditional_escape(old_short), conditional_escape(new_short)
152+
153+
def get_obj_diff_display(self) -> "ObjDiffDisplay":
154+
"""
155+
Return an instance of ``ObjDiffDisplay`` that will be used in
156+
``stringify_delta_change_values()`` to display the difference between
157+
the old and new values of a ``ModelChange``.
158+
"""
159+
return ObjDiffDisplay(max_length=self.max_displayed_delta_change_chars)
119160

120161

121162
class ObjDiffDisplay:
@@ -158,45 +199,47 @@ def common_shorten_repr(self, *args: Any) -> Tuple[str, ...]:
158199
so that the first differences between the strings (after a potential common
159200
prefix in all of them) are lined up.
160201
"""
161-
args = tuple(map(self.safe_repr, args))
162-
maxlen = max(map(len, args))
163-
if maxlen <= self.max_length:
202+
args = tuple(map(conditional_str, args))
203+
max_len = max(map(len, args))
204+
if max_len <= self.max_length:
164205
return args
165206

166207
prefix = commonprefix(args)
167-
prefixlen = len(prefix)
208+
prefix_len = len(prefix)
168209

169210
common_len = self.max_length - (
170-
maxlen - prefixlen + self.min_begin_len + self.placeholder_len
211+
max_len - prefix_len + self.min_begin_len + self.placeholder_len
171212
)
172213
if common_len > self.min_common_len:
173214
assert (
174215
self.min_begin_len
175216
+ self.placeholder_len
176217
+ self.min_common_len
177-
+ (maxlen - prefixlen)
218+
+ (max_len - prefix_len)
178219
< self.max_length
179220
) # nosec
180221
prefix = self.shorten(prefix, self.min_begin_len, common_len)
181-
return tuple(prefix + s[prefixlen:] for s in args)
222+
return tuple(f"{prefix}{s[prefix_len:]}" for s in args)
182223

183224
prefix = self.shorten(prefix, self.min_begin_len, self.min_common_len)
184225
return tuple(
185-
prefix + self.shorten(s[prefixlen:], self.min_diff_len, self.min_end_len)
226+
prefix + self.shorten(s[prefix_len:], self.min_diff_len, self.min_end_len)
186227
for s in args
187228
)
188229

189-
def safe_repr(self, obj: Any, short=False) -> str:
190-
try:
191-
result = repr(obj)
192-
except Exception:
193-
result = object.__repr__(obj)
194-
if not short or len(result) < self.max_length:
195-
return result
196-
return result[: self.max_length] + " [truncated]..."
197-
198-
def shorten(self, s: str, prefixlen: int, suffixlen: int) -> str:
199-
skip = len(s) - prefixlen - suffixlen
230+
def shorten(self, s: str, prefix_len: int, suffix_len: int) -> str:
231+
skip = len(s) - prefix_len - suffix_len
200232
if skip > self.placeholder_len:
201-
s = "%s[%d chars]%s" % (s[:prefixlen], skip, s[len(s) - suffixlen :])
233+
suffix_index = len(s) - suffix_len
234+
s = self.shortened_str(s[:prefix_len], skip, s[suffix_index:])
202235
return s
236+
237+
def shortened_str(self, prefix: str, num_skipped_chars: int, suffix: str) -> str:
238+
"""
239+
Return a shortened version of the string representation of one of the args
240+
passed to ``common_shorten_repr()``.
241+
This should be in the format ``f"{prefix}{skip_str}{suffix}"``, where
242+
``skip_str`` is a string indicating how many characters (``num_skipped_chars``)
243+
of the string representation were skipped between ``prefix`` and ``suffix``.
244+
"""
245+
return f"{prefix}[{num_skipped_chars:d} chars]{suffix}"

simple_history/tests/admin.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django.contrib import admin
2+
from django.utils.safestring import SafeString, mark_safe
23

34
from simple_history.admin import SimpleHistoryAdmin
5+
from simple_history.template_utils import HistoricalRecordContextHelper
46
from simple_history.tests.external.models import ExternalModelWithCustomUserIdField
57

68
from .models import (
@@ -12,6 +14,7 @@
1214
FileModel,
1315
Paper,
1416
Person,
17+
Place,
1518
Planet,
1619
Poll,
1720
PollWithManyToMany,
@@ -44,6 +47,27 @@ def test_method(self, obj):
4447
history_list_display = ["title", "test_method"]
4548

4649

50+
class HistoricalPollWithManyToManyContextHelper(HistoricalRecordContextHelper):
51+
def prepare_delta_change_value(self, change, value):
52+
display_value = super().prepare_delta_change_value(change, value)
53+
if change.field == "places":
54+
assert isinstance(display_value, list)
55+
assert all(isinstance(place, Place) for place in display_value)
56+
57+
places = sorted(display_value, key=lambda place: place.name)
58+
display_value = list(map(self.place_display, places))
59+
return display_value
60+
61+
@staticmethod
62+
def place_display(place: Place) -> SafeString:
63+
return mark_safe(f"<b>{place.name}</b>")
64+
65+
66+
class PollWithManyToManyAdmin(SimpleHistoryAdmin):
67+
def get_historical_record_context_helper(self, request, historical_record):
68+
return HistoricalPollWithManyToManyContextHelper(self.model, historical_record)
69+
70+
4771
admin.site.register(Book, SimpleHistoryAdmin)
4872
admin.site.register(Choice, ChoiceAdmin)
4973
admin.site.register(ConcreteExternal, SimpleHistoryAdmin)
@@ -55,4 +79,4 @@ def test_method(self, obj):
5579
admin.site.register(Person, PersonAdmin)
5680
admin.site.register(Planet, PlanetAdmin)
5781
admin.site.register(Poll, SimpleHistoryAdmin)
58-
admin.site.register(PollWithManyToMany, SimpleHistoryAdmin)
82+
admin.site.register(PollWithManyToMany, PollWithManyToManyAdmin)

simple_history/tests/tests/test_admin.py

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ def test_history_list_contains_diff_changes_for_foreign_key_fields(self):
159159
self.assertContains(response, f"Deleted poll (pk={poll1_pk})")
160160
self.assertContains(response, f"Deleted poll (pk={poll2_pk})")
161161

162+
@patch(
163+
# Test without the customization in PollWithManyToMany's admin class
164+
"simple_history.tests.admin.HistoricalPollWithManyToManyContextHelper",
165+
HistoricalRecordContextHelper,
166+
)
162167
def test_history_list_contains_diff_changes_for_m2m_fields(self):
163168
self.login()
164169
poll = PollWithManyToMany(question="why?", pub_date=today)
@@ -199,20 +204,54 @@ def test_history_list_contains_diff_changes_for_m2m_fields(self):
199204

200205
def test_history_list_doesnt_contain_too_long_diff_changes(self):
201206
self.login()
202-
repeated_chars = Poll._meta.get_field("question").max_length - 1
203-
poll = Poll(question=f"W{'A' * repeated_chars}", pub_date=today)
204-
poll._history_user = self.user
205-
poll.save()
206-
poll.question = f"W{'E' * repeated_chars}"
207-
poll.save()
208207

209-
response = self.client.get(get_history_url(poll))
210-
self.assertContains(response, "Question")
208+
def create_and_change_poll(*, initial_question, changed_question) -> Poll:
209+
poll = Poll(question=initial_question, pub_date=today)
210+
poll._history_user = self.user
211+
poll.save()
212+
poll.question = changed_question
213+
poll.save()
214+
return poll
215+
211216
repeated_chars = (
212-
HistoricalRecordContextHelper.DEFAULT_MAX_DISPLAYED_DELTA_CHANGE_CHARS - 2
217+
HistoricalRecordContextHelper.DEFAULT_MAX_DISPLAYED_DELTA_CHANGE_CHARS
218+
)
219+
220+
# Number of characters right on the limit
221+
poll1 = create_and_change_poll(
222+
initial_question="A" * repeated_chars,
223+
changed_question="B" * repeated_chars,
213224
)
214-
self.assertContains(response, f"W{'A' * repeated_chars}…")
215-
self.assertContains(response, f"W{'E' * repeated_chars}…")
225+
response = self.client.get(get_history_url(poll1))
226+
self.assertContains(response, "Question:")
227+
self.assertContains(response, "A" * repeated_chars)
228+
self.assertContains(response, "B" * repeated_chars)
229+
230+
# Number of characters just over the limit
231+
poll2 = create_and_change_poll(
232+
initial_question="A" * (repeated_chars + 1),
233+
changed_question="B" * (repeated_chars + 1),
234+
)
235+
response = self.client.get(get_history_url(poll2))
236+
self.assertContains(response, "Question:")
237+
self.assertContains(response, f"{'A' * 61}[35 chars]AAAAA")
238+
self.assertContains(response, f"{'B' * 61}[35 chars]BBBBB")
239+
240+
def test_overriding__historical_record_context_helper__with_custom_m2m_string(self):
241+
self.login()
242+
243+
place1 = Place.objects.create(name="Place 1")
244+
place2 = Place.objects.create(name="Place 2")
245+
place3 = Place.objects.create(name="Place 3")
246+
poll = PollWithManyToMany.objects.create(question="why?", pub_date=today)
247+
poll.places.add(place1, place2)
248+
poll.places.set([place3])
249+
250+
response = self.client.get(get_history_url(poll))
251+
self.assertContains(response, "Places:")
252+
self.assertContains(response, "[]")
253+
self.assertContains(response, "[<b>Place 1</b>, <b>Place 2</b>]")
254+
self.assertContains(response, "[<b>Place 3</b>]")
216255

217256
def test_history_list_custom_fields(self):
218257
model_name = self.user._meta.model_name

0 commit comments

Comments
 (0)