From c4f735fda84e849c8527e1263e38126de0f3c602 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 19 Feb 2026 18:55:51 -0600 Subject: [PATCH 1/9] fix: make view responsible for App Info field/values that does not belong in the template, so custom apps can extend the list easier --- src/wuttaweb/templates/appinfo/index.mako | 29 ++------- src/wuttaweb/views/settings.py | 76 ++++++++++++++++++++++- 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index d296389..b7beb8a 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -7,30 +7,11 @@

Application

- - ${app.get_distribution() or f'?? - set config for `{app.appname}.app_dist`'} - - - ${app.get_version() or f'?? - set config for `{app.appname}.app_dist`'} - - - ${app.get_title()} - - - ${app.get_node_title()} - - - ${config.appdb_engine.dialect.name} - - - ${app.get_timezone_name()} - - - ${"Yes" if config.production() else "No"} - - - ${"Yes" if app.get_email_handler().sending_is_enabled() else "No"} - + % for key, info in (appinfo or {}).items(): + + ${info["value"]} + + % endfor
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index a1c9c1c..5b1e293 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -2,7 +2,7 @@ ################################################################################ # # wuttaweb -- Web App for Wutta Framework -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024-2026 Lance Edgar # # This file is part of Wutta Framework. # @@ -73,6 +73,80 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method # TODO: for tailbone backward compat with get_liburl() etc. weblib_config_prefix = None + def get_template_context(self, context): # pylint: disable=empty-docstring + """ """ + if self.listing: + context["appinfo"] = self.get_appinfo_dict() + return context + + def get_appinfo_dict(self): # pylint: disable=missing-function-docstring + return OrderedDict( + [ + ( + "distribution", + { + "label": "Distribution", + "value": self.app.get_distribution() + or f"?? - set config for `{self.app.appname}.app_dist`", + }, + ), + ( + "version", + { + "label": "Version", + "value": self.app.get_version() + or f"?? - set config for `{self.app.appname}.app_dist`", + }, + ), + ( + "app_title", + { + "label": "App Title", + "value": self.app.get_title(), + }, + ), + ( + "node_title", + { + "label": "Node Title", + "value": self.app.get_node_title(), + }, + ), + ( + "db_backend", + { + "label": "DB Backend", + "value": self.config.appdb_engine.dialect.name, + }, + ), + ( + "timezone", + { + "label": "Timezone", + "value": self.app.get_timezone_name(), + }, + ), + ( + "production", + { + "label": "Production Mode", + "value": "Yes" if self.config.production() else "No", + }, + ), + ( + "email_enabled", + { + "label": "Email Enabled", + "value": ( + "Yes" + if self.app.get_email_handler().sending_is_enabled() + else "No" + ), + }, + ), + ] + ) + def get_grid_data( # pylint: disable=empty-docstring self, columns=None, session=None ): From 97e5a96cd684075a95ec19d1e70345a6225adc11 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 14:26:35 -0600 Subject: [PATCH 2/9] fix: track column-only labels separately for grid otherwise `set_label(.., column_only=True)` would not "remember" that the caller requested column only, e.g. when extra filters are added after the fact --- src/wuttaweb/grids/base.py | 67 +++++++++++++++++++++++++++++--------- tests/grids/test_base.py | 37 +++++++++++++++++++-- tests/views/test_batch.py | 4 +-- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 077f51a..0cc8f3f 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -119,9 +119,17 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth .. attribute:: labels - Dict of column label overrides. + Dict of column and/or filter label overrides. - See also :meth:`get_label()` and :meth:`set_label()`. + See also :attr:`column_labels`, :meth:`set_label()`, + :meth:`get_column_label()` and :meth:`get_filter_label()`. + + .. attribute:: column_labels + + Dict of label overrides for column only. + + See also :attr:`labels`, :meth:`set_label()` and + :meth:`get_column_label()`. .. attribute:: centered @@ -434,6 +442,7 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth self.key = key self.data = data self.labels = labels or {} + self.column_labels = {} self.checkable = checkable self.row_class = row_class self.actions = actions or [] @@ -628,35 +637,61 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth def set_label(self, key, label, column_only=False): """ - Set/override the label for a column. + Set/override the label for a column and/or filter. - :param key: Name of column. + :param key: Key for the column/filter. - :param label: New label for the column header. + :param label: New label for the column and/or filter. :param column_only: Boolean indicating whether the label should be applied *only* to the column header (if ``True``), vs. applying also to the filter (if ``False``). - See also :meth:`get_label()`. Label overrides are tracked via - :attr:`labels`. + See also :meth:`get_column_label()` and + :meth:`get_filter_label()`. Label overrides are tracked via + :attr:`labels` and :attr:`column_labels`. """ - self.labels[key] = label + if column_only: + self.column_labels[key] = label + else: + self.labels[key] = label + if key in self.filters: + self.filters[key].label = label - if not column_only and key in self.filters: - self.filters[key].label = label + def get_label(self, key): # pylint: disable=missing-function-docstring + warnings.warn( + "Grid.get_label() is deprecated; please use " + "get_filter_label() or get_column_label() instead", + DeprecationWarning, + stacklevel=2, + ) + return self.get_filter_label(key) - def get_label(self, key): + def get_filter_label(self, key): + """ + Returns the label text for a given filter. + + If no override is defined, the label is derived from ``key``. + + See also :meth:`set_label()` and :meth:`get_column_label()`. + """ + if key in self.labels: + return self.labels[key] + + return self.app.make_title(key) + + def get_column_label(self, key): """ Returns the label text for a given column. If no override is defined, the label is derived from ``key``. - See also :meth:`set_label()`. + See also :meth:`set_label()` and :meth:`get_filter_label()`. """ - if key in self.labels: - return self.labels[key] - return self.app.make_title(key) + if key in self.column_labels: + return self.column_labels[key] + + return self.get_filter_label(key) def set_centered(self, key, centered=True): """ @@ -2314,7 +2349,7 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth columns.append( { "field": name, - "label": self.get_label(name), + "label": self.get_column_label(name), "hidden": self.is_hidden(name), "sortable": self.is_sortable(name), "searchable": self.is_searchable(name), diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 89c0f50..3dd8208 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -198,17 +198,48 @@ class TestGrid(WebTestCase): # can replace label grid.set_label("name", "Different") self.assertEqual(grid.labels["name"], "Different") - self.assertEqual(grid.get_label("name"), "Different") + self.assertEqual(grid.get_filter_label("name"), "Different") + self.assertEqual(grid.get_column_label("name"), "Different") # can update only column, not filter self.assertEqual(grid.labels, {"name": "Different"}) + self.assertEqual(grid.column_labels, {}) self.assertIn("name", grid.filters) self.assertEqual(grid.filters["name"].label, "Different") grid.set_label("name", "COLUMN ONLY", column_only=True) - self.assertEqual(grid.get_label("name"), "COLUMN ONLY") - self.assertEqual(grid.filters["name"].label, "Different") + self.assertEqual(grid.get_column_label("name"), "COLUMN ONLY") + self.assertEqual(grid.get_filter_label("name"), "Different") + + def test_get_filter_label(self): + grid = self.make_grid(columns=["foo", "bar"]) + self.assertEqual(grid.labels, {}) + + # default derived from key + self.assertEqual(grid.get_filter_label("foo"), "Foo") + + # can override + grid.set_label("foo", "Different") + self.assertEqual(grid.get_filter_label("foo"), "Different") + + def test_get_column_label(self): + grid = self.make_grid(columns=["foo", "bar"]) + self.assertEqual(grid.labels, {}) + + # default derived from key + self.assertEqual(grid.get_column_label("foo"), "Foo") + + # can override "globally" + grid.set_label("foo", "Different") + self.assertEqual(grid.get_column_label("foo"), "Different") + + # can override for "just column" + grid.set_label("foo", "Col Lbl", column_only=True) + self.assertEqual(grid.get_column_label("foo"), "Col Lbl") def test_get_label(self): + # nb. the get_label() method is deprecated; can remove this + # test eventually + grid = self.make_grid(columns=["foo", "bar"]) self.assertEqual(grid.labels, {}) diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py index 317f731..0610e52 100644 --- a/tests/views/test_batch.py +++ b/tests/views/test_batch.py @@ -454,8 +454,8 @@ class TestBatchMasterView(WebTestCase): self.assertEqual( grid.columns, ["sequence", "status_code", "modified"] ) - self.assertIn("sequence", grid.labels) - self.assertEqual(grid.labels["sequence"], "Seq.") + self.assertIn("sequence", grid.column_labels) + self.assertEqual(grid.column_labels["sequence"], "Seq.") self.assertEqual(grid.tools, {}) # missing 'sequence' column From ec18ce7116a3f70a097c7c3a35908f572980e0a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 14:35:43 -0600 Subject: [PATCH 3/9] fix: allow passing filter factory to `Grid.set_filter()` --- src/wuttaweb/grids/base.py | 34 +++++++++++++++++++++------------- tests/grids/test_base.py | 7 +++++-- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 0cc8f3f..2ac2646 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -1404,10 +1404,11 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth Code usually does not need to call this directly. See also :meth:`set_filter()`, which calls this method automatically. - :param columninfo: Can be either a model property (see below), - or a column name. + :param columninfo: Can be either a model property + (e.g. ``model.User.username``), or a column name + (e.g. ``"username"``). - :returns: A :class:`~wuttaweb.grids.filters.GridFilter` + :returns: :class:`~wuttaweb.grids.filters.GridFilter` instance. """ key = kwargs.pop("key", None) @@ -1445,12 +1446,18 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth :param key: Name of column. - :param filterinfo: Can be either a - :class:`~wuttweb.grids.filters.GridFilter` instance, or - else a model property (see below). + :param filterinfo: Can be either a filter factory, or else a + model property (e.g. ``model.User.username``) or column + name (e.g. ``"username"``). If not specified then the + ``key`` will be used instead. - If ``filterinfo`` is a ``GridFilter`` instance, it will be - used as-is for the backend filter. + :param \\**kwargs: Additional kwargs to pass along to the + filter factory. + + If ``filterinfo`` is a factory, it will be called with the + current request, key and kwargs like so:: + + filtr = factory(self.request, key, **kwargs) Otherwise :meth:`make_filter()` will be called to obtain the backend filter. The ``filterinfo`` will be passed along to @@ -1462,12 +1469,13 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth filtr = None if filterinfo and callable(filterinfo): - # filtr = filterinfo - raise NotImplementedError + kwargs.setdefault("label", self.get_filter_label(key)) + filtr = filterinfo(self.request, key, **kwargs) - kwargs["key"] = key - kwargs.setdefault("label", self.get_label(key)) - filtr = self.make_filter(filterinfo or key, **kwargs) + else: + kwargs["key"] = key + kwargs.setdefault("label", self.get_filter_label(key)) + filtr = self.make_filter(filterinfo or key, **kwargs) self.filters[key] = filtr diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 3dd8208..1e193ea 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -1310,10 +1310,13 @@ class TestGrid(WebTestCase): grid.set_filter("name") self.assertIn("name", grid.filters) - # explicit is not yet implemented + # auto from filter factory grid = self.make_grid(model_class=model.Setting) self.assertEqual(grid.filters, {}) - self.assertRaises(NotImplementedError, grid.set_filter, "name", lambda q: q) + grid.set_filter( + "name", StringAlchemyFilter, model_property=model.Setting.name + ) + self.assertIn("name", grid.filters) def test_remove_filter(self): model = self.app.model From faae9f1b0a14eae9c6a7bd40f14fc16752ea943a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 14:39:42 -0600 Subject: [PATCH 4/9] fix: tweak how grid filter params are built, for better link share --- src/wuttaweb/templates/grids/vue_template.mako | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index d6e1398..47720fa 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -653,9 +653,21 @@ params[filter.key+'.verb'] = filter.verb } } - if (Object.keys(params).length) { - params.filter = 'true' - } + + ## nb. we used to add filter=true only if some + ## filter(s) is currently active, but that can cause + ## problems when sharing a link for a grid which + ## *currently* has no active filters, but which does + ## have *default* filters. so for now we always + ## declare filters to be "in effect" even if there + ## are none active. hopefully that does not break + ## anything else but this note is here just in case. + + ## if (Object.keys(params).length) { + ## params.filter = 'true' + ## } + params.filter = 'true' + return params }, From 485c503212bf76f749e54b2e10f0b969e117a6a1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 20 Feb 2026 19:09:26 -0600 Subject: [PATCH 5/9] fix: raise better error if field widget serialization fails and log a warning with traceback while we're at it --- src/wuttaweb/forms/base.py | 13 ++++++++++++- tests/forms/test_base.py | 8 ++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index b1e142a..796fd6f 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -1138,7 +1138,18 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth field = dform[fieldname] if readonly: kwargs["readonly"] = True - html = field.serialize(**kwargs) + + try: + html = field.serialize(**kwargs) + except Exception as exc: + log.warning( + "widget serialization failed for field: %s", + fieldname, + exc_info=True, + ) + raise RuntimeError( + f"widget serialization failed for field: {fieldname}" + ) from exc else: # render static text if field not in deform/schema diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index 804b360..63f807a 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -587,6 +587,14 @@ class TestForm(WebTestCase): html = form.render_vue_field("foo") self.assertIn("something is wrong", html) + class BadWidget(deform.widget.Widget): + def serialize(self, **kwargs): + raise NotImplementedError + + # widget serialization error + dform["foo"].widget = BadWidget() + self.assertRaises(RuntimeError, form.render_vue_field, "foo") + # add another field, but not to deform, so it should still # display but with no widget form.fields.append("zanzibar") From cd1c536555b9abadf29c40e9a082510b580443d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Feb 2026 13:35:02 -0600 Subject: [PATCH 6/9] fix: preserve original data type when un-setting filter choices --- src/wuttaweb/grids/filters.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/wuttaweb/grids/filters.py b/src/wuttaweb/grids/filters.py index 170fbd9..54fce1c 100644 --- a/src/wuttaweb/grids/filters.py +++ b/src/wuttaweb/grids/filters.py @@ -189,6 +189,10 @@ class GridFilter: # pylint: disable=too-many-instance-attributes self.app = self.config.get_app() self.label = label or self.app.make_title(self.key) + # remember original data type in case we need to revert, + # e.g. after changing it to 'choices' and back again + self.original_data_type = self.data_type + # active self.default_active = default_active self.active = self.default_active @@ -201,7 +205,7 @@ class GridFilter: # pylint: disable=too-many-instance-attributes self.verb = None # active verb is set later # choices - self.set_choices(choices or {}) + self.set_choices(choices) # nullable self.nullable = nullable @@ -304,7 +308,8 @@ class GridFilter: # pylint: disable=too-many-instance-attributes self.data_type = "choice" else: self.choices = {} - self.data_type = "string" + if self.data_type == "choice": + self.data_type = self.original_data_type def normalize_choices(self, choices): """ From 25c25c06e3407250e85a964fca5592d4a6b02739 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Feb 2026 13:35:38 -0600 Subject: [PATCH 7/9] fix: move `coerce_value()` method to base `GridFilter` class this should be standard practice for non-alchemy filters too --- src/wuttaweb/grids/filters.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/wuttaweb/grids/filters.py b/src/wuttaweb/grids/filters.py index 54fce1c..f34d848 100644 --- a/src/wuttaweb/grids/filters.py +++ b/src/wuttaweb/grids/filters.py @@ -350,6 +350,26 @@ class GridFilter: # pylint: disable=too-many-instance-attributes return normalized + def coerce_value(self, value): + """ + Coerce the given value to the correct type/format for use with + the filter. This is where e.g. a boolean or date filter + should convert input string to ``bool`` or ``date`` value. + + This is (usually) called from a filter method, when applying + the filter. See also :meth:`apply_filter()`. + + Default logic on the base class returns value as-is; subclass + may override as needed. + + :param value: Input string provided by the user via the filter + form submission. + + :returns: Value of the appropriate type, depending on the + filter subclass. + """ + return value + def apply_filter(self, data, verb=None, value=UNSPECIFIED): """ Filter the given data set according to a verb/value pair. @@ -420,15 +440,6 @@ class AlchemyFilter(GridFilter): if len(columns) == 1: self.nullable = columns[0].nullable - def coerce_value(self, value): - """ - Coerce the given value to the correct type/format for use with - the filter. - - Default logic returns value as-is; subclass may override. - """ - return value - def filter_equal(self, query, value): """ Filter data with an equal (``=``) condition. From 94d29c639a73e4ef435cbc48bf6b40af732a4788 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 21 Feb 2026 13:36:25 -0600 Subject: [PATCH 8/9] fix: only show string filter input if data type matches apparently `v-else` was not doing the right thing there --- src/wuttaweb/templates/wutta-components.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako index 093121d..e79c894 100644 --- a/src/wuttaweb/templates/wutta-components.mako +++ b/src/wuttaweb/templates/wutta-components.mako @@ -623,7 +623,7 @@ - Date: Wed, 25 Feb 2026 09:01:00 -0600 Subject: [PATCH 9/9] =?UTF-8?q?bump:=20version=200.28.1=20=E2=86=92=200.28?= =?UTF-8?q?.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9849d37..899001e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.28.2 (2026-02-25) + +### Fix + +- only show string filter input if data type matches +- move `coerce_value()` method to base `GridFilter` class +- preserve original data type when un-setting filter choices +- raise better error if field widget serialization fails +- tweak how grid filter params are built, for better link share +- allow passing filter factory to `Grid.set_filter()` +- track column-only labels separately for grid +- make view responsible for App Info field/values + ## v0.28.1 (2026-02-17) ### Fix diff --git a/pyproject.toml b/pyproject.toml index ad847e2..5b7eb80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.28.1" +version = "0.28.2" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]