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

99 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-20 21:14 -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 = ["field name", "removing", "keeping", "final"] 

94 

95 def render_field_row(self, field): 

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

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

98 

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

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

101 

102 kw = {} 

103 if self.cell_padding: 

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

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

106 

107 td_old_value = HTML.tag( 

108 "td", 

109 c=self.render_old_value(field), 

110 **self.get_old_value_attrs(keep_diff), 

111 ) 

112 

113 td_new_value = HTML.tag( 

114 "td", 

115 c=self.render_new_value(field), 

116 **self.get_new_value_attrs(keep_diff), 

117 ) 

118 

119 td_final_value = HTML.tag( 

120 "td", 

121 c=self.render_final_value(field), 

122 **self.get_final_value_attrs(final_diff), 

123 ) 

124 

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

126 

127 def get_final_value_attrs( 

128 self, is_diff 

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

130 attrs = {} 

131 if is_diff: 

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

133 return self.get_cell_attrs(**attrs) 

134 

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

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

137 return self.render_cell_value(value) 

138 

139 

140class VersionDiff(WebDiff): 

141 """ 

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

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

144 for the constructor. 

145 

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

147 

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

149 

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

151 :class:`WebDiff` constructor. 

152 """ 

153 

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

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

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

157 render_operation_type, 

158 ) 

159 

160 self.version = version 

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

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

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

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

165 

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

167 

168 if "nature" not in kwargs: 

169 if ( 

170 version.previous 

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

172 ): 

173 kwargs["nature"] = "delete" 

174 elif version.previous: 

175 kwargs["nature"] = "update" 

176 else: 

177 kwargs["nature"] = "create" 

178 

179 if "fields" not in kwargs: 

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

181 

182 old_data = {} 

183 new_data = {} 

184 for field in kwargs["fields"]: 

185 if version.previous: 

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

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

188 

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

190 

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

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

193 

194 unwanted = [ 

195 "transaction_id", 

196 "end_transaction_id", 

197 "operation_type", 

198 ] 

199 

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

201 

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

203 """ 

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

205 

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

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

208 monospace font by default. However: 

209 

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

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

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

213 object (if found). 

214 

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

216 

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

218 

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

220 version object. 

221 

222 :returns: Rendered cell value as HTML literal 

223 """ 

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

225 # be embedded within a more complex result. 

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

227 

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

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

230 

231 # check for standard datetime field 

232 if self.mapper.has_property(field): 

233 prop = self.mapper.get_property(field) 

234 if isinstance(prop, orm.ColumnProperty): 

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

236 col = prop.columns[0] 

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

238 if value: 

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

240 display = HTML.tag( 

241 "span", 

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

243 style=bold, 

244 ) 

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

246 

247 # loop thru all mapped relationship props 

248 for prop in self.mapper.relationships: 

249 

250 # we only want singletons 

251 if prop.uselist: 

252 continue 

253 

254 # loop thru columns for prop 

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

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

257 # harm in looping i assume.. 

258 for col in prop.local_columns: 

259 

260 # we only want the matching column 

261 if col.name != field: 

262 continue 

263 

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

265 # would be like a UserVersion for instance. 

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

267 

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

269 # like a User for instance. 

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

271 

272 # render text w/ related object as bold string 

273 return HTML.tag( 

274 "span", 

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

276 ) 

277 

278 return text 

279 

280 def render_old_value(self, field): 

281 if self.nature == "create": 

282 return "" 

283 value = self.old_value(field) 

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

285 

286 def render_new_value(self, field): 

287 if self.nature == "delete": 

288 return "" 

289 value = self.new_value(field) 

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