diff --git a/CHANGELOG.md b/CHANGELOG.md
index 899001e..47940ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,20 @@ 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.29.0 (2026-03-04)
+
+### Feat
+
+- allow widget factory override for `ObjectRef` schema type
+- add way to declare related versions for history in MasterView
+
+### Fix
+
+- expose default grid pagesize in appinfo config
+- fix timezone edge case for `WuttaDateWidget`
+- sort roles by name when viewing user
+- make pylint happy
+
## v0.28.2 (2026-02-25)
### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 5b7eb80..d22a6d7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
-version = "0.28.2"
+version = "0.29.0"
description = "Web App for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@@ -61,7 +61,7 @@ dependencies = [
[project.optional-dependencies]
-continuum = ["Wutta-Continuum>=0.3.0"]
+continuum = ["Wutta-Continuum>=0.3.3"]
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"]
tests = ["pylint", "pytest", "pytest-cov", "tox", "WebTest"]
diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py
index e002c0b..20f957b 100644
--- a/src/wuttaweb/forms/schema.py
+++ b/src/wuttaweb/forms/schema.py
@@ -457,6 +457,7 @@ class ObjectRef(colander.SchemaType):
:returns: Instance of
:class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
"""
+ factory = kwargs.pop("factory", widgets.ObjectRefWidget)
if "values" not in kwargs:
query = self.get_query()
@@ -469,7 +470,7 @@ class ObjectRef(colander.SchemaType):
if "url" not in kwargs:
kwargs["url"] = self.get_object_url
- return widgets.ObjectRefWidget(self.request, **kwargs)
+ return factory(self.request, **kwargs)
def get_object_url(self, obj):
"""
diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py
index 93be573..ac65667 100644
--- a/src/wuttaweb/forms/widgets.py
+++ b/src/wuttaweb/forms/widgets.py
@@ -234,7 +234,10 @@ class WuttaDateWidget(DateInputWidget):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly and cstruct:
- dt = datetime.datetime.fromisoformat(cstruct)
+ try:
+ dt = datetime.date.fromisoformat(cstruct)
+ except ValueError:
+ dt = datetime.datetime.fromisoformat(cstruct)
return self.app.render_date(dt)
return super().serialize(field, cstruct, **kw)
diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako
index fc0d886..2cc4dc9 100644
--- a/src/wuttaweb/templates/appinfo/configure.mako
+++ b/src/wuttaweb/templates/appinfo/configure.mako
@@ -158,6 +158,24 @@
+
Grids
+
+
+
+
+
+
+
+
+
+
+
Web Libraries
@@ -348,6 +366,8 @@
ThisPageData.validators.push(ThisPage.methods.timezoneValidate)
+ ThisPageData.gridPagesizeOptions = ${json.dumps(grid_pagesize_options)|n}
+
ThisPageData.weblibs = ${json.dumps(weblibs or [])|n}
ThisPageData.editWebLibraryShowDialog = false
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 1f919c1..5f48e50 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -1248,8 +1248,11 @@ class MasterView(View): # pylint: disable=too-many-public-methods
Default query will locate SQLAlchemy-Continuum ``transaction``
records which are associated with versions of the given model
- instance. See also
- :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`.
+ instance. See also:
+
+ * :meth:`get_version_joins()`
+ * :meth:`normalize_version_joins()`
+ * :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`
:returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance
"""
@@ -1260,9 +1263,67 @@ class MasterView(View): # pylint: disable=too-many-public-methods
model_class = self.get_model_class()
txncls = continuum.transaction_class(model_class)
- query = model_transaction_query(instance)
+ query = model_transaction_query(instance, joins=self.normalize_version_joins())
return query.order_by(txncls.issued_at.desc())
+ def get_version_joins(self):
+ """
+ Override this method to declare additional version tables
+ which should be joined when showing the overall revision
+ history for a given model instance.
+
+ Note that whatever this method returns, will be ran through
+ :meth:`normalize_version_joins()` before being passed along to
+ :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
+
+ :returns: List of version joins info as described below.
+
+ In the simple scenario where an "extension" table is involved,
+ e.g. a ``UserExtension`` table::
+
+ def get_version_joins(self):
+ model = self.app.model
+ return super().get_version_joins() + [
+ model.UserExtension,
+ ]
+
+ In the case where a secondary table is "related" to the main
+ model table, but not a standard extension (using the
+ ``User.person`` relationship as example)::
+
+ def get_version_joins(self):
+ model = self.app.model
+ return super().get_version_joins() + [
+ (model.Person, "uuid", "person_uuid"),
+ ]
+
+ See also :meth:`get_version_grid_data()`.
+ """
+ return []
+
+ def normalize_version_joins(self):
+ """
+ This method calls :meth:`get_version_joins()` and normalizes
+ the result, which will then get passed along to
+ :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
+
+ Subclass should (generally) not override this, but instead
+ override :meth:`get_version_joins()`.
+
+ Each element in the return value (list) will be a 3-tuple
+ conforming to what is needed for the query function.
+
+ See also :meth:`get_version_grid_data()`.
+
+ :returns: List of version joins info.
+ """
+ joins = []
+ for join in self.get_version_joins():
+ if not isinstance(join, tuple):
+ join = (join, "uuid", "uuid")
+ joins.append(join)
+ return joins
+
def configure_version_grid(self, g):
"""
Configure the grid for the :meth:`view_versions()` view.
@@ -1326,7 +1387,9 @@ class MasterView(View): # pylint: disable=too-many-public-methods
model_class = self.get_model_class()
route_prefix = self.get_route_prefix()
txncls = continuum.transaction_class(model_class)
- transactions = model_transaction_query(instance)
+ transactions = model_transaction_query(
+ instance, joins=self.normalize_version_joins()
+ )
txnid = self.request.matchdict["txnid"]
txn = transactions.filter(txncls.id == txnid).first()
@@ -1408,15 +1471,34 @@ class MasterView(View): # pylint: disable=too-many-public-methods
:returns: List of version records.
"""
+ import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
+
session = self.Session()
vercls = self.get_model_version_class()
- return (
+ versions = []
+
+ # first get all versions for the model instance proper
+ versions.extend(
session.query(vercls)
.filter(vercls.transaction == transaction)
.filter(vercls.uuid == instance.uuid)
.all()
)
+ # then get all related versions, per declared joins
+ for child_class, foreign_attr, primary_attr in self.normalize_version_joins():
+ child_vercls = continuum.version_class(child_class)
+ versions.extend(
+ session.query(child_vercls)
+ .filter(child_vercls.transaction == transaction)
+ .filter(
+ getattr(child_vercls, foreign_attr)
+ == getattr(instance, primary_attr)
+ )
+ )
+
+ return versions
+
##############################
# autocomplete methods
##############################
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py
index 5b1e293..04a529e 100644
--- a/src/wuttaweb/views/settings.py
+++ b/src/wuttaweb/views/settings.py
@@ -228,6 +228,8 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method
{"name": f"{self.config.appname}.email.default.to"},
{"name": f"{self.config.appname}.email.feedback.subject"},
{"name": f"{self.config.appname}.email.feedback.to"},
+ # grids
+ {"name": "wuttaweb.grids.default_pagesize", "type": int},
]
def getval(key):
@@ -282,6 +284,11 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method
handlers = [{"spec": spec} for spec in handlers]
context["menu_handlers"] = handlers
+ # add pagesize options
+ g = self.make_grid()
+ context["grid_pagesize_options"] = g.get_pagesize_options()
+ context["grid_pagesize_default"] = g.get_pagesize()
+
# add `weblibs` to context, based on config values
weblibs = self.get_weblibs()
for key in weblibs:
diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py
index 818c38a..deb44ab 100644
--- a/tests/forms/test_widgets.py
+++ b/tests/forms/test_widgets.py
@@ -139,6 +139,9 @@ class TestWuttaDateWidget(WebTestCase):
return mod.WuttaDateWidget(self.request, **kwargs)
def test_serialize(self):
+ self.config.setdefault("wuttatest.timezone.default", "America/Los_Angeles")
+ tzlocal = get_timezone_by_name("America/Los_Angeles")
+
node = colander.SchemaNode(colander.Date())
field = self.make_field(node)
@@ -156,7 +159,9 @@ class TestWuttaDateWidget(WebTestCase):
# now try again with datetime
widget = self.make_widget()
- dt = datetime.datetime(2025, 1, 15, 8, 35)
+ # nb. local zone is Los_Angeles but this is presumed to be "naive UTC"
+ # hence local date is 2025-01-14
+ dt = datetime.datetime(2025, 1, 15, 4, 35)
# editable widget has normal picker html
result = widget.serialize(field, str(dt))
@@ -164,7 +169,7 @@ class TestWuttaDateWidget(WebTestCase):
# readonly is rendered per app convention
result = widget.serialize(field, str(dt), readonly=True)
- self.assertEqual(result, "2025-01-15")
+ self.assertEqual(result, "2025-01-14")
class TestWuttaDateTimeWidget(WebTestCase):
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 6050edf..58d1c29 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -1785,6 +1785,8 @@ class TestMasterView(WebTestCase):
kw = original_context(**kw)
kw["menu_handlers"] = []
kw["default_timezone"] = "UTC"
+ kw["grid_pagesize_options"] = [10, 20, 50]
+ kw["grid_pagesize_default"] = 20
return kw
with patch.object(view, "configure_get_context", new=get_context):
@@ -2212,6 +2214,26 @@ class TestVersionedMasterView(VersionWebTestCase):
view = self.make_view()
self.assertEqual(view.get_version_grid_columns(), ["issued_at", "user"])
+ def test_get_version_joins(self):
+ view = self.make_view()
+ self.assertEqual(view.get_version_joins(), [])
+
+ def test_normalize_version_joins(self):
+ model = self.app.model
+ view = self.make_view()
+
+ joins = [(model.Person, "uuid", "person_uuid")]
+ with patch.object(view, "get_version_joins", return_value=joins):
+ normal = view.normalize_version_joins()
+ self.assertEqual(normal, joins)
+ self.assertEqual(normal, [(model.Person, "uuid", "person_uuid")])
+
+ joins = [model.Person]
+ with patch.object(view, "get_version_joins", return_value=joins):
+ normal = view.normalize_version_joins()
+ self.assertNotEqual(normal, joins)
+ self.assertEqual(normal, [(model.Person, "uuid", "uuid")])
+
def test_get_version_grid_data(self):
model = self.app.model
@@ -2300,7 +2322,9 @@ class TestVersionedMasterView(VersionWebTestCase):
txncls = continuum.transaction_class(model.User)
vercls = continuum.version_class(model.User)
- user = model.User(username="fred")
+ person = model.Person(full_name="Fred Flintstone")
+ self.session.add(person)
+ user = model.User(username="fred", person=person)
self.session.add(user)
self.session.commit()
@@ -2314,11 +2338,21 @@ class TestVersionedMasterView(VersionWebTestCase):
with patch.object(mod.MasterView, "model_class", new=model.User):
with patch.object(mod.MasterView, "Session", return_value=self.session):
view = self.make_view()
+
+ # just one version if no joins are specified
versions = view.get_relevant_versions(txn, user)
self.assertEqual(len(versions), 1)
version = versions[0]
self.assertIsInstance(version, vercls)
+ # but two versions if we specify join
+ joins = [(model.Person, "uuid", "person_uuid")]
+ with patch.object(view, "get_version_joins", return_value=joins):
+ versions = view.get_relevant_versions(txn, user)
+ self.assertEqual(len(versions), 2)
+ types = sorted([v.__class__.__name__ for v in versions])
+ self.assertEqual(types, ["PersonVersion", "UserVersion"])
+
def test_view_version(self):
import sqlalchemy_continuum as continuum