3
3
from typing import Any , Dict , Final , List , Tuple , Type , Union
4
4
5
5
from django .db .models import ManyToManyField , Model
6
- from django .template .defaultfilters import truncatechars_html
7
6
from django .utils .html import conditional_escape
7
+ from django .utils .safestring import SafeString , mark_safe
8
8
from django .utils .text import capfirst
9
9
10
10
from .models import HistoricalChanges , ModelChange , ModelDelta , PKOrRelatedObj
11
11
from .utils import get_m2m_reverse_field_name
12
12
13
13
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
+
14
33
class HistoricalRecordContextHelper :
15
34
"""
16
35
Class containing various utilities for formatting the template context for
@@ -58,17 +77,17 @@ def format_delta_change(self, change: ModelChange) -> ModelChange:
58
77
Return a ``ModelChange`` object with fields formatted for being used as
59
78
template context.
60
79
"""
80
+ old = self .prepare_delta_change_value (change , change .old )
81
+ new = self .prepare_delta_change_value (change , change .new )
61
82
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 )
65
84
66
85
field_meta = self .model ._meta .get_field (change .field )
67
86
return dataclasses .replace (
68
87
change ,
69
88
field = capfirst (field_meta .verbose_name ),
70
- old = format_value ( change . old ) ,
71
- new = format_value ( change . new ) ,
89
+ old = old ,
90
+ new = new ,
72
91
)
73
92
74
93
def prepare_delta_change_value (
@@ -78,12 +97,11 @@ def prepare_delta_change_value(
78
97
) -> Any :
79
98
"""
80
99
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
82
101
``format_delta_change()``).
83
102
84
103
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.
87
105
88
106
:param change:
89
107
:param value: Either ``change.old`` or ``change.new``.
@@ -99,23 +117,46 @@ def prepare_delta_change_value(
99
117
display_value = value
100
118
return display_value
101
119
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 ]:
103
123
"""
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()``.
106
126
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).
110
131
"""
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 ))} ]'
115
132
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 )
119
160
120
161
121
162
class ObjDiffDisplay :
@@ -158,45 +199,47 @@ def common_shorten_repr(self, *args: Any) -> Tuple[str, ...]:
158
199
so that the first differences between the strings (after a potential common
159
200
prefix in all of them) are lined up.
160
201
"""
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 :
164
205
return args
165
206
166
207
prefix = commonprefix (args )
167
- prefixlen = len (prefix )
208
+ prefix_len = len (prefix )
168
209
169
210
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
171
212
)
172
213
if common_len > self .min_common_len :
173
214
assert (
174
215
self .min_begin_len
175
216
+ self .placeholder_len
176
217
+ self .min_common_len
177
- + (maxlen - prefixlen )
218
+ + (max_len - prefix_len )
178
219
< self .max_length
179
220
) # nosec
180
221
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 )
182
223
183
224
prefix = self .shorten (prefix , self .min_begin_len , self .min_common_len )
184
225
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 )
186
227
for s in args
187
228
)
188
229
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
200
232
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 :])
202
235
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 } "
0 commit comments