# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024-2026 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tools for displaying simple data diffs
"""
import sqlalchemy as sa
from sqlalchemy import orm
from pyramid.renderers import render
from webhelpers2.html import HTML
from wuttjamaican.diffs import Diff
[docs]
class WebDiff(Diff):
"""
Simple diff class for the web app.
This is based on the
:class:`~wuttjamaican:wuttjamaican.diffs.Diff` class; it just
tweaks :meth:`render_html()` to use the web template lookup
engine.
"""
cell_padding = None
[docs]
def render_html(self, template="/diff.mako", **kwargs):
"""
Render the diff as HTML table.
:param template: Name of template to render, if you need to
override the default.
:param \\**kwargs: Remaining kwargs are passed as context to
the template renderer.
:returns: HTML literal string
"""
context = kwargs
context["diff"] = self
html = render(template, context)
return HTML.literal(html)
[docs]
class MergeDiff(WebDiff):
"""
Special diff class for use when merging 2 records. While based on
:class:`WebDiff`, this class uses a different signature for the
constructor.
It shows the "removing" record, the "keeping" record, and also the
"final" record showing the calculated result of the merge, with
special highlighting where values would change on the kept record.
:param config: The app :term:`config object`.
:param removing_data: Dict of data for the "removing" record.
:param new_data: Dict of data for the "keeping" record.
:param new_data: Dict of "final" data for the kept record.
:param \\**kwargs: Remaining kwargs are passed as-is to the
:class:`WebDiff` constructor.
"""
def __init__(self, config, removing_data, keeping_data, final_data, **kwargs):
super().__init__(config, removing_data, keeping_data, **kwargs)
self.removing_data = removing_data
self.keeping_data = keeping_data
self.final_data = final_data
self.columns = ["field name", "removing", "keeping", "final"]
def render_field_row(self, field):
keep_diff = self.keeping_data.get(field) != self.removing_data.get(field)
final_diff = self.final_data.get(field) != self.keeping_data.get(field)
# TODO: there is a fair bit of duplication here, compared to
# base class. should maybe clean that up someday..
kw = {}
if self.cell_padding:
kw["style"] = f"padding: {self.cell_padding}"
td_field = HTML.tag("td", class_="field", c=field, **kw)
td_old_value = HTML.tag(
"td",
c=self.render_old_value(field),
**self.get_old_value_attrs(keep_diff),
)
td_new_value = HTML.tag(
"td",
c=self.render_new_value(field),
**self.get_new_value_attrs(keep_diff),
)
td_final_value = HTML.tag(
"td",
c=self.render_final_value(field),
**self.get_final_value_attrs(final_diff),
)
return HTML.tag("tr", c=[td_field, td_old_value, td_new_value, td_final_value])
def get_final_value_attrs(
self, is_diff
): # pylint: disable=missing-function-docstring
attrs = {}
if is_diff:
attrs["class_"] = "has-background-warning"
return self.get_cell_attrs(**attrs)
def render_final_value(self, field): # pylint: disable=missing-function-docstring
value = repr(self.final_data.get(field))
return self.render_cell_value(value)
[docs]
class VersionDiff(WebDiff):
"""
Special diff class for use with version history views. While
based on :class:`WebDiff`, this class uses a different signature
for the constructor.
:param config: The app :term:`config object`.
:param version: Reference to a Continuum version record object.
:param \\**kwargs: Remaining kwargs are passed as-is to the
:class:`WebDiff` constructor.
"""
def __init__(self, config, version, **kwargs):
import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
render_operation_type,
)
self.version = version
self.model_class = continuum.parent_class(type(self.version))
self.mapper = sa.inspect(self.model_class)
self.version_mapper = sa.inspect(type(self.version))
self.title = kwargs.pop("title", self.model_class.__name__)
self.operation_title = render_operation_type(self.version.operation_type)
if "nature" not in kwargs:
if (
version.previous
and version.operation_type == continuum.Operation.DELETE
):
kwargs["nature"] = "delete"
elif version.previous:
kwargs["nature"] = "update"
else:
kwargs["nature"] = "create"
if "fields" not in kwargs:
kwargs["fields"] = self.get_default_fields()
old_data = {}
new_data = {}
for field in kwargs["fields"]:
if version.previous:
old_data[field] = getattr(version.previous, field)
new_data[field] = getattr(version, field)
super().__init__(config, old_data, new_data, **kwargs)
def get_default_fields(self): # pylint: disable=missing-function-docstring
fields = sorted(self.version_mapper.columns.keys())
unwanted = [
"transaction_id",
"end_transaction_id",
"operation_type",
]
return [field for field in fields if field not in unwanted]
[docs]
def render_version_value(self, version, field, value):
"""
Render the cell value HTML for a given version + field.
This method is used to render both sides of the diff (old +
new values). It will just render the field value using a
monospace font by default. However:
If the field is involved in a mapper relationship (i.e. it is
the "foreign key" to a related table), the logic here will
also (try to) traverse that show display text for the related
object (if found).
:param version: Reference to the Continuum version object.
:param field: Name of the field, as string.
:param value: Raw value for the field, as obtained from the
version object.
:returns: Rendered cell value as HTML literal
"""
# first render normal span; this is our fallback but also may
# be embedded within a more complex result.
text = HTML.tag("span", c=[repr(value)], style="font-family: monospace;")
# style to apply for bold human-friendly text (if applicable)
bold = "margin-left: 2rem; font-style: italic; font-weight: bold;"
# check for standard datetime field
if self.mapper.has_property(field):
prop = self.mapper.get_property(field)
if isinstance(prop, orm.ColumnProperty):
if len(prop.columns) == 1:
col = prop.columns[0]
if isinstance(col.type, sa.DateTime):
if value:
# render as local datetime w/ "time since" tooltip
display = HTML.tag(
"span",
c=self.app.render_datetime(value, html=True),
style=bold,
)
return HTML.tag("span", c=[text, display])
# loop thru all mapped relationship props
for prop in self.mapper.relationships:
# we only want singletons
if prop.uselist:
continue
# loop thru columns for prop
# nb. there should always be just one colum for a
# singleton prop, but technically a list is used, so no
# harm in looping i assume..
for col in prop.local_columns:
# we only want the matching column
if col.name != field:
continue
# grab "related version" reference via prop key. this
# would be like a UserVersion for instance.
if ref := getattr(version, prop.key):
# grab "related object" reference. this would be
# like a User for instance.
if ref := getattr(ref, "version_parent", None):
# render text w/ related object as bold string
return HTML.tag(
"span",
c=[text, HTML.tag("span", c=[str(ref)], style=bold)],
)
return text
def render_old_value(self, field):
if self.nature == "create":
return ""
value = self.old_value(field)
return self.render_version_value(self.version.previous, field, value)
def render_new_value(self, field):
if self.nature == "delete":
return ""
value = self.new_value(field)
return self.render_version_value(self.version, field, value)