Compare commits
6 commits
637bf442fa
...
064b1b6d8a
| Author | SHA1 | Date | |
|---|---|---|---|
| 064b1b6d8a | |||
| 24f7d7ac0a | |||
| ceec4714fa | |||
| 19cf6cf03a | |||
| 634c1b1fe5 | |||
| 22a1c99abe |
8 changed files with 176 additions and 21 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,6 +5,16 @@ 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.2 (2026-03-10)
|
||||
|
||||
### Fix
|
||||
|
||||
- add base class for `WuttaList` schema type
|
||||
- skip custom rendering for missing grid columns
|
||||
- serialize None to null, for ObjectRef schema type
|
||||
- improve JSON-safe rendering for sets
|
||||
- make empty <select> option work better for WuttaDictEnum
|
||||
|
||||
## v0.29.1 (2026-03-04)
|
||||
|
||||
### Fix
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "WuttaWeb"
|
||||
version = "0.29.1"
|
||||
version = "0.29.2"
|
||||
description = "Web App for Wutta Framework"
|
||||
readme = "README.md"
|
||||
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
|
||||
|
|
|
|||
|
|
@ -205,17 +205,23 @@ class WuttaDictEnum(colander.String):
|
|||
"""
|
||||
|
||||
def __init__(self, request, enum_dct, *args, **kwargs):
|
||||
self.null_value = kwargs.pop("null_value", "")
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
self.config = self.request.wutta_config
|
||||
self.app = self.config.get_app()
|
||||
self.enum_dct = enum_dct
|
||||
|
||||
def serialize(self, node, appstruct):
|
||||
if appstruct is colander.null:
|
||||
return self.null_value
|
||||
return super().serialize(node, appstruct)
|
||||
|
||||
def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
|
||||
""" """
|
||||
if "values" not in kwargs:
|
||||
kwargs["values"] = list(self.enum_dct.items())
|
||||
|
||||
kwargs.setdefault("null_value", self.null_value)
|
||||
return widgets.SelectWidget(**kwargs)
|
||||
|
||||
|
||||
|
|
@ -274,13 +280,59 @@ class WuttaQuantity(colander.Decimal):
|
|||
return self.app.render_quantity(appstruct)
|
||||
|
||||
|
||||
class WuttaSet(colander.Set):
|
||||
class WuttaList(colander.List):
|
||||
"""
|
||||
Custom schema type for :class:`python:set` fields.
|
||||
|
||||
This is a subclass of :class:`colander.Set`.
|
||||
Custom schema type for :class:`python:list` fields; this is a
|
||||
subclass of :class:`colander.List`.
|
||||
|
||||
:param request: Current :term:`request` object.
|
||||
|
||||
As of now this merely provides a way (in fact, requires you) to
|
||||
pass the request in, so it can be leveraged as needed. Instances
|
||||
of this type will have the following attributes:
|
||||
|
||||
.. attribute:: request
|
||||
|
||||
Reference to the current :term:`request`.
|
||||
|
||||
.. attribute:: config
|
||||
|
||||
Reference to the app :term:`config object`.
|
||||
|
||||
.. attribute:: app
|
||||
|
||||
Reference to the :term:`app handler` instance.
|
||||
"""
|
||||
|
||||
def __init__(self, request):
|
||||
super().__init__()
|
||||
self.request = request
|
||||
self.config = self.request.wutta_config
|
||||
self.app = self.config.get_app()
|
||||
|
||||
|
||||
class WuttaSet(colander.Set):
|
||||
"""
|
||||
Custom schema type for :class:`python:set` fields; this is a
|
||||
subclass of :class:`colander.Set`.
|
||||
|
||||
:param request: Current :term:`request` object.
|
||||
|
||||
As of now this merely provides a way (in fact, requires you) to
|
||||
pass the request in, so it can be leveraged as needed. Instances
|
||||
of this type will have the following attributes:
|
||||
|
||||
.. attribute:: request
|
||||
|
||||
Reference to the current :term:`request`.
|
||||
|
||||
.. attribute:: config
|
||||
|
||||
Reference to the app :term:`config object`.
|
||||
|
||||
.. attribute:: app
|
||||
|
||||
Reference to the :term:`app handler` instance.
|
||||
"""
|
||||
|
||||
def __init__(self, request):
|
||||
|
|
@ -351,15 +403,17 @@ class ObjectRef(colander.SchemaType):
|
|||
|
||||
def serialize(self, node, appstruct): # pylint: disable=empty-docstring
|
||||
""" """
|
||||
# nb. normalize to empty option if no object ref, so that
|
||||
# works as expected
|
||||
# normalize to empty option if no object ref, so that works as
|
||||
# expected
|
||||
if self.empty_option and not appstruct:
|
||||
return self.empty_option[0]
|
||||
|
||||
if appstruct is colander.null:
|
||||
# even if there is no empty option, still treat any false-ish
|
||||
# value as null
|
||||
if not appstruct:
|
||||
return colander.null
|
||||
|
||||
# nb. keep a ref to this for later use
|
||||
# keep a ref to this for later use
|
||||
node.model_instance = appstruct
|
||||
|
||||
# serialize to PK as string
|
||||
|
|
|
|||
|
|
@ -2515,6 +2515,8 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth
|
|||
|
||||
# customize value rendering where applicable
|
||||
for key, renderer in self.renderers.items():
|
||||
# nb. no need to render if column not included
|
||||
if key in self.columns:
|
||||
value = record.get(key, None)
|
||||
record[key] = renderer(original_record, key, value)
|
||||
|
||||
|
|
|
|||
|
|
@ -654,6 +654,13 @@ def make_json_safe(value, key=None, warn=True):
|
|||
parent[i] = make_json_safe(v, key=key, warn=warn)
|
||||
value = parent
|
||||
|
||||
elif isinstance(value, set):
|
||||
# recursively convert set (as list)
|
||||
parent = list(value)
|
||||
for i, v in enumerate(parent):
|
||||
parent[i] = make_json_safe(v, key=key, warn=warn)
|
||||
value = parent
|
||||
|
||||
elif isinstance(value, _uuid.UUID):
|
||||
# convert UUID to str
|
||||
value = value.hex
|
||||
|
|
|
|||
|
|
@ -153,6 +153,20 @@ MOCK_STATUS = {
|
|||
|
||||
class TestWuttaDictEnum(WebTestCase):
|
||||
|
||||
def test_serialize(self):
|
||||
|
||||
# null_value is empty string by default
|
||||
typ = mod.WuttaDictEnum(self.request, MOCK_STATUS)
|
||||
node = colander.SchemaNode(typ)
|
||||
self.assertEqual(typ.serialize(node, 1), "1")
|
||||
self.assertEqual(typ.serialize(node, colander.null), "")
|
||||
|
||||
# but can override if needed (?)
|
||||
typ = mod.WuttaDictEnum(self.request, MOCK_STATUS, null_value="nope")
|
||||
node = colander.SchemaNode(typ)
|
||||
self.assertEqual(typ.serialize(node, 1), "1")
|
||||
self.assertEqual(typ.serialize(node, colander.null), "nope")
|
||||
|
||||
def test_widget_maker(self):
|
||||
typ = mod.WuttaDictEnum(self.request, MOCK_STATUS)
|
||||
widget = typ.widget_maker()
|
||||
|
|
@ -184,6 +198,26 @@ class TestWuttaMoney(WebTestCase):
|
|||
self.assertEqual(widget.scale, 4)
|
||||
|
||||
|
||||
class TestWuttaList(WebTestCase):
|
||||
|
||||
def test_constructor(self):
|
||||
node = colander.SchemaNode(mod.WuttaList(self.request))
|
||||
typ = node.typ
|
||||
self.assertIs(typ.request, self.request)
|
||||
self.assertIs(typ.config, self.config)
|
||||
self.assertIs(typ.app, self.app)
|
||||
|
||||
|
||||
class TestWuttaSet(WebTestCase):
|
||||
|
||||
def test_constructor(self):
|
||||
node = colander.SchemaNode(mod.WuttaSet(self.request))
|
||||
typ = node.typ
|
||||
self.assertIs(typ.request, self.request)
|
||||
self.assertIs(typ.config, self.config)
|
||||
self.assertIs(typ.app, self.app)
|
||||
|
||||
|
||||
class TestWuttaQuantity(WebTestCase):
|
||||
|
||||
def test_serialize(self):
|
||||
|
|
@ -243,6 +277,8 @@ class TestObjectRef(DataTestCase):
|
|||
typ = mod.ObjectRef(self.request)
|
||||
value = typ.serialize(node, colander.null)
|
||||
self.assertIs(value, colander.null)
|
||||
value = typ.serialize(node, None)
|
||||
self.assertIs(value, colander.null)
|
||||
|
||||
# model instance
|
||||
person = model.Person(full_name="Betty Boop")
|
||||
|
|
|
|||
|
|
@ -1985,11 +1985,13 @@ class TestGrid(WebTestCase):
|
|||
|
||||
# typical data is a list
|
||||
mydata = [
|
||||
{"foo": "bar"},
|
||||
{"foo": "bar", "baz": "zoo"},
|
||||
]
|
||||
grid = self.make_grid(columns=["foo"], data=mydata)
|
||||
grid = self.make_grid(columns=["foo", "baz"], data=mydata)
|
||||
context = grid.get_vue_context()
|
||||
self.assertEqual(context, {"data": [{"foo": "bar"}], "row_classes": {}})
|
||||
self.assertEqual(
|
||||
context, {"data": [{"foo": "bar", "baz": "zoo"}], "row_classes": {}}
|
||||
)
|
||||
|
||||
# non-declared columns are discarded
|
||||
mydata = [
|
||||
|
|
@ -2001,27 +2003,47 @@ class TestGrid(WebTestCase):
|
|||
|
||||
# if grid has actions, that list may be supplemented
|
||||
mydata = [
|
||||
{"foo": "bar"},
|
||||
{"foo": "bar", "baz": "zoo"},
|
||||
]
|
||||
grid = self.make_grid(columns=["foo"], data=mydata)
|
||||
grid = self.make_grid(columns=["foo", "baz"], data=mydata)
|
||||
grid.actions.append(mod.GridAction(self.request, "view", url="/blarg"))
|
||||
context = grid.get_vue_context()
|
||||
self.assertIsNot(context["data"], mydata)
|
||||
self.assertEqual(
|
||||
context,
|
||||
{"data": [{"foo": "bar", "_action_url_view": "/blarg"}], "row_classes": {}},
|
||||
{
|
||||
"data": [{"foo": "bar", "baz": "zoo", "_action_url_view": "/blarg"}],
|
||||
"row_classes": {},
|
||||
},
|
||||
)
|
||||
|
||||
# can override value rendering
|
||||
grid.set_renderer("foo", lambda record, key, value: "blah blah")
|
||||
renderer = MagicMock(return_value="blah blah")
|
||||
grid.set_renderer("foo", renderer)
|
||||
context = grid.get_vue_context()
|
||||
self.assertEqual(
|
||||
context,
|
||||
{
|
||||
"data": [{"foo": "blah blah", "_action_url_view": "/blarg"}],
|
||||
"data": [
|
||||
{"foo": "blah blah", "baz": "zoo", "_action_url_view": "/blarg"}
|
||||
],
|
||||
"row_classes": {},
|
||||
},
|
||||
)
|
||||
renderer.assert_called_once_with({"foo": "bar", "baz": "zoo"}, "foo", "bar")
|
||||
|
||||
# custom rendering skipped if column not included
|
||||
grid.remove("foo")
|
||||
renderer.reset_mock()
|
||||
context = grid.get_vue_context()
|
||||
self.assertEqual(
|
||||
context,
|
||||
{
|
||||
"data": [{"baz": "zoo", "_action_url_view": "/blarg"}],
|
||||
"row_classes": {},
|
||||
},
|
||||
)
|
||||
renderer.assert_not_called()
|
||||
|
||||
# can set row class
|
||||
grid.row_class = "whatever"
|
||||
|
|
@ -2029,7 +2051,7 @@ class TestGrid(WebTestCase):
|
|||
self.assertEqual(
|
||||
context,
|
||||
{
|
||||
"data": [{"foo": "blah blah", "_action_url_view": "/blarg"}],
|
||||
"data": [{"baz": "zoo", "_action_url_view": "/blarg"}],
|
||||
"row_classes": {"0": "whatever"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -735,6 +735,30 @@ class TestMakeJsonSafe(TestCase):
|
|||
],
|
||||
)
|
||||
|
||||
def test_set(self):
|
||||
model = self.app.model
|
||||
person = model.Person(full_name="Betty Boop")
|
||||
|
||||
data = {
|
||||
"foo",
|
||||
"bar",
|
||||
person,
|
||||
}
|
||||
|
||||
self.assertRaises(TypeError, json.dumps, data)
|
||||
value = mod.make_json_safe(data)
|
||||
self.assertNotIsInstance(value, set)
|
||||
self.assertIsInstance(value, list)
|
||||
# nb. must sort values, otherwise comparison may fail
|
||||
self.assertEqual(
|
||||
sorted(value),
|
||||
[
|
||||
"Betty Boop", # nb. upper-case sorts first
|
||||
"bar",
|
||||
"foo",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestRenderVueFinalize(TestCase):
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue