From cba8e4774d06188446e44dac1ba758f2903f83f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 12:59:20 -0600 Subject: [PATCH 1/5] feat: add way to declare related versions for history in MasterView --- pyproject.toml | 2 +- src/wuttaweb/views/master.py | 92 ++++++++++++++++++++++++++++++++++-- tests/views/test_master.py | 34 ++++++++++++- 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b7eb80..b3eaeb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/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/tests/views/test_master.py b/tests/views/test_master.py index 6050edf..db1491e 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -2212,6 +2212,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 +2320,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 +2336,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 From 590441c0bead581d0e3f13c262ba1e991165e9d2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 16:27:27 -0600 Subject: [PATCH 2/5] feat: allow widget factory override for `ObjectRef` schema type --- src/wuttaweb/forms/schema.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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): """ From 7cfe6e15f4e38ce8ccccc5558c72adf27f25c481 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 27 Feb 2026 16:29:17 -0600 Subject: [PATCH 3/5] fix: fix timezone edge case for `WuttaDateWidget` --- src/wuttaweb/forms/widgets.py | 5 ++++- tests/forms/test_widgets.py | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) 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/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): From cc52c708db6489e6717bd7763bfe2dcbd5c16ff8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 18:26:21 -0600 Subject: [PATCH 4/5] fix: expose default grid pagesize in appinfo config --- src/wuttaweb/templates/appinfo/configure.mako | 20 +++++++++++++++++++ src/wuttaweb/views/settings.py | 7 +++++++ tests/views/test_master.py | 2 ++ 3 files changed, 29 insertions(+) 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/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/views/test_master.py b/tests/views/test_master.py index db1491e..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): From de7fc4dcf08157d30e5063a3702bb80d72019cb5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 4 Mar 2026 14:09:35 -0600 Subject: [PATCH 5/5] =?UTF-8?q?bump:=20version=200.28.2=20=E2=86=92=200.29?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) 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 b3eaeb5..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"}]