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
« 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"""
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 = kwargs.get(
94 "columns", ["field name", "removing", "keeping", "final"]
95 )
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)
101 # TODO: there is a fair bit of duplication here, compared to
102 # base class. should maybe clean that up someday..
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)
109 td_old_value = HTML.tag(
110 "td",
111 c=self.render_old_value(field),
112 **self.get_old_value_attrs(keep_diff),
113 )
115 td_new_value = HTML.tag(
116 "td",
117 c=self.render_new_value(field),
118 **self.get_new_value_attrs(keep_diff),
119 )
121 td_final_value = HTML.tag(
122 "td",
123 c=self.render_final_value(field),
124 **self.get_final_value_attrs(final_diff),
125 )
127 return HTML.tag("tr", c=[td_field, td_old_value, td_new_value, td_final_value])
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)
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)
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.
148 :param config: The app :term:`config object`.
150 :param version: Reference to a Continuum version record object.
152 :param \\**kwargs: Remaining kwargs are passed as-is to the
153 :class:`WebDiff` constructor.
154 """
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 )
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__)
168 self.operation_title = render_operation_type(self.version.operation_type)
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"
181 if "fields" not in kwargs:
182 kwargs["fields"] = self.get_default_fields()
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)
191 super().__init__(config, old_data, new_data, **kwargs)
193 def get_default_fields(self): # pylint: disable=missing-function-docstring
194 fields = sorted(self.version_mapper.columns.keys())
196 unwanted = [
197 "transaction_id",
198 "end_transaction_id",
199 "operation_type",
200 ]
202 return [field for field in fields if field not in unwanted]
204 def render_version_value(self, version, field, value):
205 """
206 Render the cell value HTML for a given version + field.
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:
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).
217 :param version: Reference to the Continuum version object.
219 :param field: Name of the field, as string.
221 :param value: Raw value for the field, as obtained from the
222 version object.
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;")
230 # style to apply for bold human-friendly text (if applicable)
231 bold = "margin-left: 2rem; font-style: italic; font-weight: bold;"
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])
249 # loop thru all mapped relationship props
250 for prop in self.mapper.relationships:
252 # we only want singletons
253 if prop.uselist:
254 continue
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:
262 # we only want the matching column
263 if col.name != field:
264 continue
266 # grab "related version" reference via prop key. this
267 # would be like a UserVersion for instance.
268 if ref := getattr(version, prop.key):
270 # grab "related object" reference. this would be
271 # like a User for instance.
272 if ref := getattr(ref, "version_parent", None):
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 )
280 return text
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)
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)