diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbba860..7e13a5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,17 @@ All notable changes to wuttaweb will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.25.1 (2025-12-20)
+
+### Fix
+
+- add `WebDiff` class now that `Diff` lives in wuttjamaican
+- expose fallback key for email settings
+- expose transaction comment for version history
+- show display text for related objects, in version diff
+- discard non-declared field values for grid vue data
+- prevent error in DateTime schema type if no widget/request set
+
## v0.25.0 (2025-12-17)
### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 1cd984b..17e3c94 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
-version = "0.25.0"
+version = "0.25.1"
description = "Web App for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@@ -44,13 +44,13 @@ dependencies = [
"pyramid_tm",
"waitress",
"WebHelpers2",
- "WuttJamaican[db]>=0.26.0",
+ "WuttJamaican[db]>=0.27.0",
"zope.sqlalchemy>=1.5",
]
[project.optional-dependencies]
-continuum = ["Wutta-Continuum>=0.2.2"]
+continuum = ["Wutta-Continuum>=0.3.0"]
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"]
tests = ["pylint", "pytest", "pytest-cov", "tox"]
diff --git a/src/wuttaweb/diffs.py b/src/wuttaweb/diffs.py
index 9747fd0..cb59ed5 100644
--- a/src/wuttaweb/diffs.py
+++ b/src/wuttaweb/diffs.py
@@ -29,60 +29,20 @@ import sqlalchemy as sa
from pyramid.renderers import render
from webhelpers2.html import HTML
+from wuttjamaican.diffs import Diff
-class Diff:
+
+class WebDiff(Diff):
"""
- Represent / display a basic "diff" between two data records.
+ Simple diff class for the web app.
- You must provide both the "old" and "new" data records, when
- constructing an instance of this class. Then call
- :meth:`render_html()` to display the diff table.
-
- :param old_data: Dict of "old" data record.
-
- :param new_data: Dict of "new" data record.
-
- :param fields: Optional list of field names. If not specified,
- will be derived from the data records.
-
- :param nature: What sort of diff is being represented; must be one
- of: ``("create", "update", "delete")``
-
- :param old_color: Background color to display for "old/deleted"
- field data, when applicable.
-
- :param new_color: Background color to display for "new/created"
- field data, when applicable.
+ This is based on the
+ :class:`~wuttjamaican:wuttjamaican.diffs.Diff` class; it just
+ tweaks :meth:`render_html()` to use the web template lookup
+ engine.
"""
- def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
- self,
- old_data: dict,
- new_data: dict,
- fields: list = None,
- nature="update",
- old_color="#ffebe9",
- new_color="#dafbe1",
- ):
- self.old_data = old_data
- self.new_data = new_data
- self.columns = ["field name", "old value", "new value"]
- self.fields = fields or self.make_fields()
- self.nature = nature
- self.old_color = old_color
- self.new_color = new_color
-
- def make_fields(self): # pylint: disable=missing-function-docstring
- return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower())
-
- def old_value(self, field): # pylint: disable=missing-function-docstring
- return self.old_data.get(field)
-
- def new_value(self, field): # pylint: disable=missing-function-docstring
- return self.new_data.get(field)
-
- def values_differ(self, field): # pylint: disable=missing-function-docstring
- return self.new_value(field) != self.old_value(field)
+ cell_padding = None
def render_html(self, template="/diff.mako", **kwargs):
"""
@@ -98,69 +58,25 @@ class Diff:
"""
context = kwargs
context["diff"] = self
- return HTML.literal(render(template, context))
-
- def render_field_row(self, field): # pylint: disable=missing-function-docstring
- is_diff = self.values_differ(field)
-
- td_field = HTML.tag("td", class_="field", c=field)
-
- td_old_value = HTML.tag(
- "td",
- c=self.render_old_value(field),
- **self.get_old_value_attrs(is_diff),
- )
-
- td_new_value = HTML.tag(
- "td",
- c=self.render_new_value(field),
- **self.get_new_value_attrs(is_diff),
- )
-
- return HTML.tag("tr", c=[td_field, td_old_value, td_new_value])
-
- def render_old_value(self, field): # pylint: disable=missing-function-docstring
- value = self.old_value(field)
- return repr(value)
-
- def render_new_value(self, field): # pylint: disable=missing-function-docstring
- value = self.new_value(field)
- return repr(value)
-
- def get_old_value_attrs( # pylint: disable=missing-function-docstring
- self, is_diff
- ):
- attrs = {}
- if self.nature == "update" and is_diff:
- attrs["style"] = f"background-color: {self.old_color};"
- elif self.nature == "delete":
- attrs["style"] = f"background-color: {self.old_color};"
- return attrs
-
- def get_new_value_attrs( # pylint: disable=missing-function-docstring
- self, is_diff
- ):
- attrs = {}
- if self.nature == "create":
- attrs["style"] = f"background-color: {self.new_color};"
- elif self.nature == "update" and is_diff:
- attrs["style"] = f"background-color: {self.new_color};"
- return attrs
+ html = render(template, context)
+ return HTML.literal(html)
-class VersionDiff(Diff):
+class VersionDiff(WebDiff):
"""
- Special diff class, for use with version history views. Note that
- while based on :class:`Diff`, this class uses a different
- signature for the constructor.
+ Special diff class for use with version history views. While
+ based on :class:`WebDiff`, this class uses a different signature
+ for the constructor.
- :param version: Reference to a Continuum version record (object).
+ :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:`Diff` constructor.
+ :class:`WebDiff` constructor.
"""
- def __init__(self, version, **kwargs):
+ 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,
@@ -195,7 +111,7 @@ class VersionDiff(Diff):
old_data[field] = getattr(version.previous, field)
new_data[field] = getattr(version, field)
- super().__init__(old_data, new_data, **kwargs)
+ 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())
@@ -208,17 +124,76 @@ class VersionDiff(Diff):
return [field for field in fields if field not in unwanted]
- def render_version_value(self, value): # pylint: disable=missing-function-docstring
- return HTML.tag("span", c=[repr(value)], style="font-family: monospace;")
+ 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;")
+
+ # 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
+ style = (
+ "margin-left: 2rem; font-style: italic; font-weight: bold;"
+ )
+ return HTML.tag(
+ "span",
+ c=[text, HTML.tag("span", c=[str(ref)], style=style)],
+ )
+
+ return text
def render_old_value(self, field):
if self.nature == "create":
return ""
value = self.old_value(field)
- return self.render_version_value(value)
+ 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(value)
+ return self.render_version_value(self.version, field, value)
diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py
index 37db3e9..e002c0b 100644
--- a/src/wuttaweb/forms/schema.py
+++ b/src/wuttaweb/forms/schema.py
@@ -31,6 +31,7 @@ import colander
import sqlalchemy as sa
from wuttjamaican.conf import parse_list
+from wuttjamaican.util import localtime
from wuttaweb.db import Session
from wuttaweb.forms import widgets
@@ -38,28 +39,38 @@ from wuttaweb.forms import widgets
class WuttaDateTime(colander.DateTime):
"""
- Custom schema type for ``datetime`` fields.
+ Custom schema type for :class:`~python:datetime.datetime` fields.
This should be used automatically for
- :class:`sqlalchemy:sqlalchemy.types.DateTime` columns unless you
- register another default.
+ :class:`~sqlalchemy:sqlalchemy.types.DateTime` ORM columns unless
+ you register another default.
This schema type exists for sake of convenience, when working with
the Buefy datepicker + timepicker widgets.
+
+ It also follows the datetime handling "rules" as outlined in
+ :doc:`wuttjamaican:narr/datetime`. On the Python side, values
+ should be naive/UTC datetime objects. On the HTTP side, values
+ will be ISO-format strings representing aware/local time.
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
- request = node.widget.request
- config = request.wutta_config
- app = config.get_app()
+ # nb. request should be present when it matters
+ if node.widget and node.widget.request:
+ request = node.widget.request
+ config = request.wutta_config
+ app = config.get_app()
+ appstruct = app.localtime(appstruct)
+ else:
+ # but if not, fallback to config-less logic
+ appstruct = localtime(appstruct)
- dt = app.localtime(appstruct)
if self.format:
- return dt.strftime(self.format)
- return dt.isoformat()
+ return appstruct.strftime(self.format)
+ return appstruct.isoformat()
def deserialize( # pylint: disable=inconsistent-return-statements
self, node, cstruct
@@ -72,6 +83,7 @@ class WuttaDateTime(colander.DateTime):
"%Y-%m-%dT%I:%M %p",
]
+ # nb. request is always assumed to be present here
request = node.widget.request
config = request.wutta_config
app = config.get_app()
diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index 81c92ae..f0f45a1 100644
--- a/src/wuttaweb/grids/base.py
+++ b/src/wuttaweb/grids/base.py
@@ -2390,6 +2390,9 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth
# convert record to new dict
record = self.object_to_dict(record)
+ # discard non-declared fields
+ record = {field: record[field] for field in record if field in self.columns}
+
# make all values safe for json
record = make_json_safe(record, warn=False)
diff --git a/src/wuttaweb/templates/master/view_version.mako b/src/wuttaweb/templates/master/view_version.mako
index 361055e..8e838e8 100644
--- a/src/wuttaweb/templates/master/view_version.mako
+++ b/src/wuttaweb/templates/master/view_version.mako
@@ -24,6 +24,10 @@
${transaction.id}
+