33from typing import Any , Dict , Final , List , Tuple , Type , Union
44
55from django .db .models import ManyToManyField , Model
6- from django .template .defaultfilters import truncatechars_html
76from django .utils .html import conditional_escape
7+ from django .utils .safestring import SafeString , mark_safe
88from django .utils .text import capfirst
99
1010from .models import HistoricalChanges , ModelChange , ModelDelta , PKOrRelatedObj
1111from .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+
1433class 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
121162class 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 } "
0 commit comments