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
« 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"""
27import sqlalchemy as sa
28from sqlalchemy import orm
30from pyramid.renderers import render
31from webhelpers2.html import HTML
33from wuttjamaican.diffs import Diff
36class WebDiff(Diff):
37 """
38 Simple diff class for the web app.
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 """
46 cell_padding = None
48 def render_html(self, template="/diff.mako", **kwargs):
49 """
50 Render the diff as HTML table.
52 :param template: Name of template to render, if you need to
53 override the default.
55 :param \\**kwargs: Remaining kwargs are passed as context to
56 the template renderer.
58 :returns: HTML literal string
59 """
60 context = kwargs
61 context["diff"] = self
62 html = render(template, context)
63 return HTML.literal(html)
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.
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.
76 :param config: The app :term:`config object`.
78 :param removing_data: Dict of data for the "removing" record.
80 :param new_data: Dict of data for the "keeping" record.
82 :param new_data: Dict of "final" data for the kept record.
84 :param \\**kwargs: Remaining kwargs are passed as-is to the
85 :class:`WebDiff` constructor.
86 """
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"]
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)
99 # TODO: there is a fair bit of duplication here, compared to
100 # base class. should maybe clean that up someday..
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)
107 td_old_value = HTML.tag(
108 "td",
109 c=self.render_old_value(field),
110 **self.get_old_value_attrs(keep_diff),
111 )
113 td_new_value = HTML.tag(
114 "td",
115 c=self.render_new_value(field),
116 **self.get_new_value_attrs(keep_diff),
117 )
119 td_final_value = HTML.tag(
120 "td",
121 c=self.render_final_value(field),
122 **self.get_final_value_attrs(final_diff),
123 )
125 return HTML.tag("tr", c=[td_field, td_old_value, td_new_value, td_final_value])
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)
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)
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.
146 :param config: The app :term:`config object`.
148 :param version: Reference to a Continuum version record object.
150 :param \\**kwargs: Remaining kwargs are passed as-is to the
151 :class:`WebDiff` constructor.
152 """
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 )
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__)
166 self.operation_title = render_operation_type(self.version.operation_type)
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"
179 if "fields" not in kwargs:
180 kwargs["fields"] = self.get_default_fields()
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)
189 super().__init__(config, old_data, new_data, **kwargs)
191 def get_default_fields(self): # pylint: disable=missing-function-docstring
192 fields = sorted(self.version_mapper.columns.keys())
194 unwanted = [
195 "transaction_id",
196 "end_transaction_id",
197 "operation_type",
198 ]
200 return [field for field in fields if field not in unwanted]
202 def render_version_value(self, version, field, value):
203 """
204 Render the cell value HTML for a given version + field.
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:
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).
215 :param version: Reference to the Continuum version object.
217 :param field: Name of the field, as string.
219 :param value: Raw value for the field, as obtained from the
220 version object.
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;")
228 # style to apply for bold human-friendly text (if applicable)
229 bold = "margin-left: 2rem; font-style: italic; font-weight: bold;"
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])
247 # loop thru all mapped relationship props
248 for prop in self.mapper.relationships:
250 # we only want singletons
251 if prop.uselist:
252 continue
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:
260 # we only want the matching column
261 if col.name != field:
262 continue
264 # grab "related version" reference via prop key. this
265 # would be like a UserVersion for instance.
266 if ref := getattr(version, prop.key):
268 # grab "related object" reference. this would be
269 # like a User for instance.
270 if ref := getattr(ref, "version_parent", None):
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 )
278 return text
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)
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)