Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/diffs.py: 100%

99 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 16:36 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2026 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Tools for displaying simple data diffs 

25""" 

26 

27import sqlalchemy as sa 

28from sqlalchemy import orm 

29 

30from pyramid.renderers import render 

31from webhelpers2.html import HTML 

32 

33from wuttjamaican.diffs import Diff 

34 

35 

36class WebDiff(Diff): 

37 """ 

38 Simple diff class for the web app. 

39 

40 This is based on the 

41 :class:`~wuttjamaican:wuttjamaican.diffs.Diff` class; it just 

42 tweaks :meth:`render_html()` to use the web template lookup 

43 engine. 

44 """ 

45 

46 cell_padding = None 

47 

48 def render_html(self, template="/diff.mako", **kwargs): 

49 """ 

50 Render the diff as HTML table. 

51 

52 :param template: Name of template to render, if you need to 

53 override the default. 

54 

55 :param \\**kwargs: Remaining kwargs are passed as context to 

56 the template renderer. 

57 

58 :returns: HTML literal string 

59 """ 

60 context = kwargs 

61 context["diff"] = self 

62 html = render(template, context) 

63 return HTML.literal(html) 

64 

65 

66class MergeDiff(WebDiff): 

67 """ 

68 Special diff class for use when merging 2 records. While based on 

69 :class:`WebDiff`, this class uses a different signature for the 

70 constructor. 

71 

72 It shows the "removing" record, the "keeping" record, and also the 

73 "final" record showing the calculated result of the merge, with 

74 special highlighting where values would change on the kept record. 

75 

76 :param config: The app :term:`config object`. 

77 

78 :param removing_data: Dict of data for the "removing" record. 

79 

80 :param new_data: Dict of data for the "keeping" record. 

81 

82 :param new_data: Dict of "final" data for the kept record. 

83 

84 :param \\**kwargs: Remaining kwargs are passed as-is to the 

85 :class:`WebDiff` constructor. 

86 """ 

87 

88 def __init__(self, config, removing_data, keeping_data, final_data, **kwargs): 

89 super().__init__(config, removing_data, keeping_data, **kwargs) 

90 self.removing_data = removing_data 

91 self.keeping_data = keeping_data 

92 self.final_data = final_data 

93 self.columns = kwargs.get( 

94 "columns", ["field name", "removing", "keeping", "final"] 

95 ) 

96 

97 def render_field_row(self, field): 

98 keep_diff = self.keeping_data.get(field) != self.removing_data.get(field) 

99 final_diff = self.final_data.get(field) != self.keeping_data.get(field) 

100 

101 # TODO: there is a fair bit of duplication here, compared to 

102 # base class. should maybe clean that up someday.. 

103 

104 kw = {} 

105 if self.cell_padding: 

106 kw["style"] = f"padding: {self.cell_padding}" 

107 td_field = HTML.tag("td", class_="field", c=field, **kw) 

108 

109 td_old_value = HTML.tag( 

110 "td", 

111 c=self.render_old_value(field), 

112 **self.get_old_value_attrs(keep_diff), 

113 ) 

114 

115 td_new_value = HTML.tag( 

116 "td", 

117 c=self.render_new_value(field), 

118 **self.get_new_value_attrs(keep_diff), 

119 ) 

120 

121 td_final_value = HTML.tag( 

122 "td", 

123 c=self.render_final_value(field), 

124 **self.get_final_value_attrs(final_diff), 

125 ) 

126 

127 return HTML.tag("tr", c=[td_field, td_old_value, td_new_value, td_final_value]) 

128 

129 def get_final_value_attrs( 

130 self, is_diff 

131 ): # pylint: disable=missing-function-docstring 

132 attrs = {} 

133 if is_diff: 

134 attrs["class_"] = "has-background-warning" 

135 return self.get_cell_attrs(**attrs) 

136 

137 def render_final_value(self, field): # pylint: disable=missing-function-docstring 

138 value = repr(self.final_data.get(field)) 

139 return self.render_cell_value(value) 

140 

141 

142class VersionDiff(WebDiff): 

143 """ 

144 Special diff class for use with version history views. While 

145 based on :class:`WebDiff`, this class uses a different signature 

146 for the constructor. 

147 

148 :param config: The app :term:`config object`. 

149 

150 :param version: Reference to a Continuum version record object. 

151 

152 :param \\**kwargs: Remaining kwargs are passed as-is to the 

153 :class:`WebDiff` constructor. 

154 """ 

155 

156 def __init__(self, config, version, **kwargs): 

157 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel 

158 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel 

159 render_operation_type, 

160 ) 

161 

162 self.version = version 

163 self.model_class = continuum.parent_class(type(self.version)) 

164 self.mapper = sa.inspect(self.model_class) 

165 self.version_mapper = sa.inspect(type(self.version)) 

166 self.title = kwargs.pop("title", self.model_class.__name__) 

167 

168 self.operation_title = render_operation_type(self.version.operation_type) 

169 

170 if "nature" not in kwargs: 

171 if ( 

172 version.previous 

173 and version.operation_type == continuum.Operation.DELETE 

174 ): 

175 kwargs["nature"] = "delete" 

176 elif version.previous: 

177 kwargs["nature"] = "update" 

178 else: 

179 kwargs["nature"] = "create" 

180 

181 if "fields" not in kwargs: 

182 kwargs["fields"] = self.get_default_fields() 

183 

184 old_data = {} 

185 new_data = {} 

186 for field in kwargs["fields"]: 

187 if version.previous: 

188 old_data[field] = getattr(version.previous, field) 

189 new_data[field] = getattr(version, field) 

190 

191 super().__init__(config, old_data, new_data, **kwargs) 

192 

193 def get_default_fields(self): # pylint: disable=missing-function-docstring 

194 fields = sorted(self.version_mapper.columns.keys()) 

195 

196 unwanted = [ 

197 "transaction_id", 

198 "end_transaction_id", 

199 "operation_type", 

200 ] 

201 

202 return [field for field in fields if field not in unwanted] 

203 

204 def render_version_value(self, version, field, value): 

205 """ 

206 Render the cell value HTML for a given version + field. 

207 

208 This method is used to render both sides of the diff (old + 

209 new values). It will just render the field value using a 

210 monospace font by default. However: 

211 

212 If the field is involved in a mapper relationship (i.e. it is 

213 the "foreign key" to a related table), the logic here will 

214 also (try to) traverse that show display text for the related 

215 object (if found). 

216 

217 :param version: Reference to the Continuum version object. 

218 

219 :param field: Name of the field, as string. 

220 

221 :param value: Raw value for the field, as obtained from the 

222 version object. 

223 

224 :returns: Rendered cell value as HTML literal 

225 """ 

226 # first render normal span; this is our fallback but also may 

227 # be embedded within a more complex result. 

228 text = HTML.tag("span", c=[repr(value)], style="font-family: monospace;") 

229 

230 # style to apply for bold human-friendly text (if applicable) 

231 bold = "margin-left: 2rem; font-style: italic; font-weight: bold;" 

232 

233 # check for standard datetime field 

234 if self.mapper.has_property(field): 

235 prop = self.mapper.get_property(field) 

236 if isinstance(prop, orm.ColumnProperty): 

237 if len(prop.columns) == 1: 

238 col = prop.columns[0] 

239 if isinstance(col.type, sa.DateTime): 

240 if value: 

241 # render as local datetime w/ "time since" tooltip 

242 display = HTML.tag( 

243 "span", 

244 c=self.app.render_datetime(value, html=True), 

245 style=bold, 

246 ) 

247 return HTML.tag("span", c=[text, display]) 

248 

249 # loop thru all mapped relationship props 

250 for prop in self.mapper.relationships: 

251 

252 # we only want singletons 

253 if prop.uselist: 

254 continue 

255 

256 # loop thru columns for prop 

257 # nb. there should always be just one colum for a 

258 # singleton prop, but technically a list is used, so no 

259 # harm in looping i assume.. 

260 for col in prop.local_columns: 

261 

262 # we only want the matching column 

263 if col.name != field: 

264 continue 

265 

266 # grab "related version" reference via prop key. this 

267 # would be like a UserVersion for instance. 

268 if ref := getattr(version, prop.key): 

269 

270 # grab "related object" reference. this would be 

271 # like a User for instance. 

272 if ref := getattr(ref, "version_parent", None): 

273 

274 # render text w/ related object as bold string 

275 return HTML.tag( 

276 "span", 

277 c=[text, HTML.tag("span", c=[str(ref)], style=bold)], 

278 ) 

279 

280 return text 

281 

282 def render_old_value(self, field): 

283 if self.nature == "create": 

284 return "" 

285 value = self.old_value(field) 

286 return self.render_version_value(self.version.previous, field, value) 

287 

288 def render_new_value(self, field): 

289 if self.nature == "delete": 

290 return "" 

291 value = self.new_value(field) 

292 return self.render_version_value(self.version, field, value)