3
0
Fork 0

Compare commits

...

9 commits

Author SHA1 Message Date
7fcc24ce0d bump: version 0.28.1 → 0.28.2 2026-02-25 09:01:00 -06:00
94d29c639a fix: only show string filter input if data type matches
apparently `v-else` was not doing the right thing there
2026-02-21 13:36:25 -06:00
25c25c06e3 fix: move coerce_value() method to base GridFilter class
this should be standard practice for non-alchemy filters too
2026-02-21 13:35:38 -06:00
cd1c536555 fix: preserve original data type when un-setting filter choices 2026-02-21 13:35:02 -06:00
485c503212 fix: raise better error if field widget serialization fails
and log a warning with traceback while we're at it
2026-02-20 19:09:26 -06:00
faae9f1b0a fix: tweak how grid filter params are built, for better link share 2026-02-20 14:43:25 -06:00
ec18ce7116 fix: allow passing filter factory to Grid.set_filter() 2026-02-20 14:43:25 -06:00
97e5a96cd6 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
2026-02-20 14:43:22 -06:00
c4f735fda8 fix: make view responsible for App Info field/values
that does not belong in the template, so custom apps can extend the
list easier
2026-02-19 18:55:51 -06:00
12 changed files with 270 additions and 78 deletions

View file

@ -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/) 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). 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) ## v0.28.1 (2026-02-17)
### Fix ### Fix

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaWeb" name = "WuttaWeb"
version = "0.28.1" version = "0.28.2"
description = "Web App for Wutta Framework" description = "Web App for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]

View file

@ -1138,7 +1138,18 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth
field = dform[fieldname] field = dform[fieldname]
if readonly: if readonly:
kwargs["readonly"] = True 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: else:
# render static text if field not in deform/schema # render static text if field not in deform/schema

View file

@ -119,9 +119,17 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth
.. attribute:: labels .. 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 .. attribute:: centered
@ -434,6 +442,7 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth
self.key = key self.key = key
self.data = data self.data = data
self.labels = labels or {} self.labels = labels or {}
self.column_labels = {}
self.checkable = checkable self.checkable = checkable
self.row_class = row_class self.row_class = row_class
self.actions = actions or [] 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): 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 :param column_only: Boolean indicating whether the label
should be applied *only* to the column header (if should be applied *only* to the column header (if
``True``), vs. applying also to the filter (if ``False``). ``True``), vs. applying also to the filter (if ``False``).
See also :meth:`get_label()`. Label overrides are tracked via See also :meth:`get_column_label()` and
:attr:`labels`. :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: def get_label(self, key): # pylint: disable=missing-function-docstring
self.filters[key].label = label 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. Returns the label text for a given column.
If no override is defined, the label is derived from ``key``. 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: if key in self.column_labels:
return self.labels[key] return self.column_labels[key]
return self.app.make_title(key)
return self.get_filter_label(key)
def set_centered(self, key, centered=True): def set_centered(self, key, centered=True):
""" """
@ -1369,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 Code usually does not need to call this directly. See also
:meth:`set_filter()`, which calls this method automatically. :meth:`set_filter()`, which calls this method automatically.
:param columninfo: Can be either a model property (see below), :param columninfo: Can be either a model property
or a column name. (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. instance.
""" """
key = kwargs.pop("key", None) key = kwargs.pop("key", None)
@ -1410,12 +1446,18 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth
:param key: Name of column. :param key: Name of column.
:param filterinfo: Can be either a :param filterinfo: Can be either a filter factory, or else a
:class:`~wuttweb.grids.filters.GridFilter` instance, or model property (e.g. ``model.User.username``) or column
else a model property (see below). name (e.g. ``"username"``). If not specified then the
``key`` will be used instead.
If ``filterinfo`` is a ``GridFilter`` instance, it will be :param \\**kwargs: Additional kwargs to pass along to the
used as-is for the backend filter. 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 Otherwise :meth:`make_filter()` will be called to obtain the
backend filter. The ``filterinfo`` will be passed along to backend filter. The ``filterinfo`` will be passed along to
@ -1427,12 +1469,13 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth
filtr = None filtr = None
if filterinfo and callable(filterinfo): if filterinfo and callable(filterinfo):
# filtr = filterinfo kwargs.setdefault("label", self.get_filter_label(key))
raise NotImplementedError filtr = filterinfo(self.request, key, **kwargs)
kwargs["key"] = key else:
kwargs.setdefault("label", self.get_label(key)) kwargs["key"] = key
filtr = self.make_filter(filterinfo or key, **kwargs) kwargs.setdefault("label", self.get_filter_label(key))
filtr = self.make_filter(filterinfo or key, **kwargs)
self.filters[key] = filtr self.filters[key] = filtr
@ -2314,7 +2357,7 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth
columns.append( columns.append(
{ {
"field": name, "field": name,
"label": self.get_label(name), "label": self.get_column_label(name),
"hidden": self.is_hidden(name), "hidden": self.is_hidden(name),
"sortable": self.is_sortable(name), "sortable": self.is_sortable(name),
"searchable": self.is_searchable(name), "searchable": self.is_searchable(name),

View file

@ -189,6 +189,10 @@ class GridFilter: # pylint: disable=too-many-instance-attributes
self.app = self.config.get_app() self.app = self.config.get_app()
self.label = label or self.app.make_title(self.key) 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 # active
self.default_active = default_active self.default_active = default_active
self.active = self.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 self.verb = None # active verb is set later
# choices # choices
self.set_choices(choices or {}) self.set_choices(choices)
# nullable # nullable
self.nullable = nullable self.nullable = nullable
@ -304,7 +308,8 @@ class GridFilter: # pylint: disable=too-many-instance-attributes
self.data_type = "choice" self.data_type = "choice"
else: else:
self.choices = {} self.choices = {}
self.data_type = "string" if self.data_type == "choice":
self.data_type = self.original_data_type
def normalize_choices(self, choices): def normalize_choices(self, choices):
""" """
@ -345,6 +350,26 @@ class GridFilter: # pylint: disable=too-many-instance-attributes
return normalized 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): def apply_filter(self, data, verb=None, value=UNSPECIFIED):
""" """
Filter the given data set according to a verb/value pair. Filter the given data set according to a verb/value pair.
@ -415,15 +440,6 @@ class AlchemyFilter(GridFilter):
if len(columns) == 1: if len(columns) == 1:
self.nullable = columns[0].nullable 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): def filter_equal(self, query, value):
""" """
Filter data with an equal (``=``) condition. Filter data with an equal (``=``) condition.

View file

@ -7,30 +7,11 @@
<p class="panel-heading">Application</p> <p class="panel-heading">Application</p>
<div class="panel-block"> <div class="panel-block">
<div style="width: 100%;"> <div style="width: 100%;">
<b-field horizontal label="Distribution"> % for key, info in (appinfo or {}).items():
<span>${app.get_distribution() or f'?? - set config for `{app.appname}.app_dist`'}</span> <b-field horizontal label="${info['label']}">
</b-field> <span>${info["value"]}</span>
<b-field horizontal label="Version"> </b-field>
<span>${app.get_version() or f'?? - set config for `{app.appname}.app_dist`'}</span> % endfor
</b-field>
<b-field horizontal label="App Title">
<span>${app.get_title()}</span>
</b-field>
<b-field horizontal label="Node Title">
<span>${app.get_node_title()}</span>
</b-field>
<b-field horizontal label="DB Backend">
<span>${config.appdb_engine.dialect.name}</span>
</b-field>
<b-field horizontal label="Time Zone">
<span>${app.get_timezone_name()}</span>
</b-field>
<b-field horizontal label="Production Mode">
<span>${"Yes" if config.production() else "No"}</span>
</b-field>
<b-field horizontal label="Email Enabled">
<span>${"Yes" if app.get_email_handler().sending_is_enabled() else "No"}</span>
</b-field>
</div> </div>
</div> </div>
</nav> </nav>

View file

@ -653,9 +653,21 @@
params[filter.key+'.verb'] = filter.verb 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 return params
}, },

View file

@ -623,7 +623,7 @@
</option> </option>
</b-select> </b-select>
<wutta-filter-value v-else <wutta-filter-value v-if="filter.data_type == 'string'"
v-model="filter.value" v-model="filter.value"
ref="filterValue" ref="filterValue"
v-show="valuedVerb()" v-show="valuedVerb()"

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# wuttaweb -- Web App for Wutta Framework # wuttaweb -- Web App for Wutta Framework
# Copyright © 2024-2025 Lance Edgar # Copyright © 2024-2026 Lance Edgar
# #
# This file is part of Wutta Framework. # 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. # TODO: for tailbone backward compat with get_liburl() etc.
weblib_config_prefix = None 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 def get_grid_data( # pylint: disable=empty-docstring
self, columns=None, session=None self, columns=None, session=None
): ):

View file

@ -587,6 +587,14 @@ class TestForm(WebTestCase):
html = form.render_vue_field("foo") html = form.render_vue_field("foo")
self.assertIn("something is wrong", html) 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 # add another field, but not to deform, so it should still
# display but with no widget # display but with no widget
form.fields.append("zanzibar") form.fields.append("zanzibar")

View file

@ -198,17 +198,48 @@ class TestGrid(WebTestCase):
# can replace label # can replace label
grid.set_label("name", "Different") grid.set_label("name", "Different")
self.assertEqual(grid.labels["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 # can update only column, not filter
self.assertEqual(grid.labels, {"name": "Different"}) self.assertEqual(grid.labels, {"name": "Different"})
self.assertEqual(grid.column_labels, {})
self.assertIn("name", grid.filters) self.assertIn("name", grid.filters)
self.assertEqual(grid.filters["name"].label, "Different") self.assertEqual(grid.filters["name"].label, "Different")
grid.set_label("name", "COLUMN ONLY", column_only=True) grid.set_label("name", "COLUMN ONLY", column_only=True)
self.assertEqual(grid.get_label("name"), "COLUMN ONLY") self.assertEqual(grid.get_column_label("name"), "COLUMN ONLY")
self.assertEqual(grid.filters["name"].label, "Different") 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): def test_get_label(self):
# nb. the get_label() method is deprecated; can remove this
# test eventually
grid = self.make_grid(columns=["foo", "bar"]) grid = self.make_grid(columns=["foo", "bar"])
self.assertEqual(grid.labels, {}) self.assertEqual(grid.labels, {})
@ -1279,10 +1310,13 @@ class TestGrid(WebTestCase):
grid.set_filter("name") grid.set_filter("name")
self.assertIn("name", grid.filters) self.assertIn("name", grid.filters)
# explicit is not yet implemented # auto from filter factory
grid = self.make_grid(model_class=model.Setting) grid = self.make_grid(model_class=model.Setting)
self.assertEqual(grid.filters, {}) 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): def test_remove_filter(self):
model = self.app.model model = self.app.model

View file

@ -454,8 +454,8 @@ class TestBatchMasterView(WebTestCase):
self.assertEqual( self.assertEqual(
grid.columns, ["sequence", "status_code", "modified"] grid.columns, ["sequence", "status_code", "modified"]
) )
self.assertIn("sequence", grid.labels) self.assertIn("sequence", grid.column_labels)
self.assertEqual(grid.labels["sequence"], "Seq.") self.assertEqual(grid.column_labels["sequence"], "Seq.")
self.assertEqual(grid.tools, {}) self.assertEqual(grid.tools, {})
# missing 'sequence' column # missing 'sequence' column