From 08a895a07b2b174eb9be9a8862f3f1c1727b489f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 29 Dec 2024 20:07:10 -0600 Subject: [PATCH 01/11] fix: use proper bulma styles for markdown content cf. https://bulma.io/documentation/elements/content/ --- src/wuttaweb/templates/batch/view.mako | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/wuttaweb/templates/batch/view.mako b/src/wuttaweb/templates/batch/view.mako index f03ab5b..1305079 100644 --- a/src/wuttaweb/templates/batch/view.mako +++ b/src/wuttaweb/templates/batch/view.mako @@ -1,19 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_styles()"> - ${parent.extra_styles()} - - - <%def name="tool_panels()"> ${parent.tool_panels()} ${self.tool_panel_execution()} @@ -65,7 +52,7 @@

What will happen when this batch is executed?

-
+
${execution_described|n}
${h.form(master.get_action_url('execute', batch), ref='executeForm')} From a612bf3846b0df6a3f2ef5af255497c32675cec9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 2 Jan 2025 20:13:04 -0600 Subject: [PATCH 02/11] fix: add grid renderers for bool, currency, quantity also set bool renderer by default when possible --- src/wuttaweb/grids/base.py | 76 +++++++++++++++++++++++++++++++------- tests/grids/test_base.py | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index b9f0de7..2259c3f 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -116,7 +116,8 @@ class Grid: Dict of column (cell) value renderer overrides. - See also :meth:`set_renderer()`. + See also :meth:`set_renderer()` and + :meth:`set_default_renderers()`. .. attribute:: row_class @@ -602,15 +603,18 @@ class Grid: """ Set default column value renderers, where applicable. - This will add new entries to :attr:`renderers` for columns - whose data type implies a default renderer should be used. - This is generally only possible if :attr:`model_class` is set - to a valid SQLAlchemy mapped class. + This is called automatically from the class constructor. It + will add new entries to :attr:`renderers` for columns whose + data type implies a default renderer. This is only possible + if :attr:`model_class` is set to a SQLAlchemy mapped class. - This (for now?) only looks for - :class:`sqlalchemy:sqlalchemy.types.DateTime` columns and if - any are found, they are configured to use - :meth:`render_datetime()`. + This only looks for a couple of data types, and configures as + follows: + + * :class:`sqlalchemy:sqlalchemy.types.Boolean` -> + :meth:`render_boolean()` + * :class:`sqlalchemy:sqlalchemy.types.DateTime` -> + :meth:`render_datetime()` """ if not self.model_class: return @@ -626,6 +630,8 @@ class Grid: column = prop.columns[0] if isinstance(column.type, sa.DateTime): self.set_renderer(key, self.render_datetime) + elif isinstance(column.type, sa.Boolean): + self.set_renderer(key, self.render_boolean) def set_link(self, key, link=True): """ @@ -1753,23 +1759,65 @@ class Grid: # rendering methods ############################## + def render_boolean(self, obj, key, value): + """ + Column renderer for boolean values. + + This calls + :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_boolean()` + for the return value. + + This may be used automatically per + :meth:`set_default_renderers()` or you can use it explicitly:: + + grid.set_renderer('foo', grid.render_boolean) + """ + return self.app.render_boolean(value) + + def render_currency(self, obj, key, value): + """ + Column renderer for currency values. + + This calls + :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()` + for the return value. + + This is not used automatically but you can use it explicitly:: + + grid.set_renderer('foo', grid.render_currency) + """ + return self.app.render_currency(value) + def render_datetime(self, obj, key, value): """ - Default cell value renderer for - :class:`sqlalchemy:sqlalchemy.types.DateTime` columns, which - calls + Column renderer for :class:`python:datetime.datetime` values. + + This calls :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()` for the return value. This may be used automatically per - :meth:`set_default_renderers()` or you can use it explicitly - for any :class:`python:datetime.datetime` column with:: + :meth:`set_default_renderers()` or you can use it explicitly:: grid.set_renderer('foo', grid.render_datetime) """ dt = getattr(obj, key) return self.app.render_datetime(dt) + def render_quantity(self, obj, key, value): + """ + Column renderer for quantity values. + + This calls + :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()` + for the return value. + + This is not used automatically but you can use it explicitly:: + + grid.set_renderer('foo', grid.render_quantity) + """ + return self.app.render_quantity(value) + def render_table_element( self, form=None, diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index f03aad2..28478d2 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -1,6 +1,7 @@ # -*- coding: utf-8; -*- import datetime +import decimal from unittest import TestCase from unittest.mock import patch, MagicMock @@ -232,6 +233,17 @@ class TestGrid(WebTestCase): self.assertIn('created', grid.renderers) self.assertIs(grid.renderers['created'], myrender) + # renderer set for boolean mapped field + grid = self.make_grid(model_class=model.Upgrade) + self.assertIn('executing', grid.renderers) + self.assertIsNot(grid.renderers['executing'], myrender) + + # renderer *not* set for boolean, if override present + grid = self.make_grid(model_class=model.Upgrade, + renderers={'executing': myrender}) + self.assertIn('executing', grid.renderers) + self.assertIs(grid.renderers['executing'], myrender) + def test_linked_columns(self): grid = self.make_grid(columns=['foo', 'bar']) self.assertEqual(grid.linked_columns, []) @@ -1331,6 +1343,62 @@ class TestGrid(WebTestCase): # rendering methods ############################## + def test_render_boolean(self): + grid = self.make_grid(columns=['foo', 'bar']) + + # null + obj = MagicMock(foo=None) + self.assertEqual(grid.render_boolean(obj, 'foo', None), "") + + # true + obj = MagicMock(foo=True) + self.assertEqual(grid.render_boolean(obj, 'foo', True), "Yes") + + # false + obj = MagicMock(foo=False) + self.assertEqual(grid.render_boolean(obj, 'foo', False), "No") + + def test_render_currency(self): + grid = self.make_grid(columns=['foo', 'bar']) + obj = MagicMock() + + # null + self.assertEqual(grid.render_currency(obj, 'foo', None), '') + + # basic decimal example + value = decimal.Decimal('42.00') + self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.00') + + # basic float example + value = 42.00 + self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.00') + + # decimal places will be rounded + value = decimal.Decimal('42.12345') + self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.12') + + # negative numbers get parens + value = decimal.Decimal('-42.42') + self.assertEqual(grid.render_currency(obj, 'foo', value), '($42.42)') + + def test_render_quantity(self): + grid = self.make_grid(columns=['foo', 'bar']) + obj = MagicMock() + + # null + self.assertEqual(grid.render_quantity(obj, 'foo', None), "") + + # integer decimals become integers + value = decimal.Decimal('1.000') + self.assertEqual(grid.render_quantity(obj, 'foo', value), "1") + + # but decimal places are preserved + value = decimal.Decimal('1.234') + self.assertEqual(grid.render_quantity(obj ,'foo', value), "1.234") + + # zero is *not* empty string (with this renderer) + self.assertEqual(grid.render_quantity(obj, 'foo', 0), "0") + def test_render_datetime(self): grid = self.make_grid(columns=['foo', 'bar']) From a219f3e30d1973e72fd761bac7b07bf1f644b799 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 2 Jan 2025 21:09:31 -0600 Subject: [PATCH 03/11] fix: remove `session` param from some form schema, widget classes this was originally used for injecting the test session, but i wound up using mock instead elsewhere, so this is just for consistency --- src/wuttaweb/forms/schema.py | 32 +++--- src/wuttaweb/forms/widgets.py | 9 +- tests/forms/test_schema.py | 200 ++++++++++++++++++---------------- tests/forms/test_widgets.py | 174 +++++++++++++++-------------- 4 files changed, 214 insertions(+), 201 deletions(-) diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 7591a7a..a7f5de2 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -181,21 +181,16 @@ class WuttaSet(colander.Set): """ Custom schema type for :class:`python:set` fields. - This is a subclass of :class:`colander.Set`, but adds - Wutta-related params to the constructor. + This is a subclass of :class:`colander.Set`. :param request: Current :term:`request` object. - - :param session: Optional :term:`db session` to use instead of - :class:`wuttaweb.db.sess.Session`. """ - def __init__(self, request, session=None): + def __init__(self, request): super().__init__() self.request = request self.config = self.request.wutta_config self.app = self.config.get_app() - self.session = session or Session() class ObjectRef(colander.SchemaType): @@ -231,7 +226,6 @@ class ObjectRef(colander.SchemaType): self, request, empty_option=None, - session=None, *args, **kwargs, ): @@ -240,7 +234,7 @@ class ObjectRef(colander.SchemaType): self.config = self.request.wutta_config self.app = self.config.get_app() self.model_instance = None - self.session = session or Session() + self.session = Session() if empty_option: if empty_option is True: @@ -472,7 +466,7 @@ class RoleRefs(WuttaSet): :returns: Instance of :class:`~wuttaweb.forms.widgets.RoleRefsWidget`. """ - kwargs.setdefault('session', self.session) + session = kwargs.setdefault('session', Session()) if 'values' not in kwargs: model = self.app.model @@ -480,20 +474,20 @@ class RoleRefs(WuttaSet): # avoid built-ins which cannot be assigned to users avoid = { - auth.get_role_authenticated(self.session), - auth.get_role_anonymous(self.session), + auth.get_role_authenticated(session), + auth.get_role_anonymous(session), } avoid = set([role.uuid for role in avoid]) # also avoid admin unless current user is root if not self.request.is_root: - avoid.add(auth.get_role_administrator(self.session).uuid) + avoid.add(auth.get_role_administrator(session).uuid) # everything else can be (un)assigned for users - roles = self.session.query(model.Role)\ - .filter(~model.Role.uuid.in_(avoid))\ - .order_by(model.Role.name)\ - .all() + roles = session.query(model.Role)\ + .filter(~model.Role.uuid.in_(avoid))\ + .order_by(model.Role.name)\ + .all() values = [(role.uuid.hex, role.name) for role in roles] kwargs['values'] = values @@ -518,7 +512,7 @@ class UserRefs(WuttaSet): :returns: Instance of :class:`~wuttaweb.forms.widgets.UserRefsWidget`. """ - kwargs.setdefault('session', self.session) + kwargs.setdefault('session', Session()) return widgets.UserRefsWidget(self.request, **kwargs) @@ -548,7 +542,7 @@ class Permissions(WuttaSet): :returns: Instance of :class:`~wuttaweb.forms.widgets.PermissionsWidget`. """ - kwargs.setdefault('session', self.session) + kwargs.setdefault('session', Session()) kwargs.setdefault('permissions', self.permissions) if 'values' not in kwargs: diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 0af8bab..7b3e4be 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -136,26 +136,21 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): Custom widget for :class:`python:set` fields. This is a subclass of - :class:`deform:deform.widget.CheckboxChoiceWidget`, but adds - Wutta-related params to the constructor. + :class:`deform:deform.widget.CheckboxChoiceWidget`. :param request: Current :term:`request` object. - :param session: Optional :term:`db session` to use instead of - :class:`wuttaweb.db.sess.Session`. - It uses these Deform templates: * ``checkbox_choice`` * ``readonly/checkbox_choice`` """ - def __init__(self, request, session=None, *args, **kwargs): + def __init__(self, request, *args, **kwargs): super().__init__(*args, **kwargs) self.request = request self.config = self.request.wutta_config self.app = self.config.get_app() - self.session = session or Session() class WuttaDateTimeWidget(DateTimeInputWidget): diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 564e8c2..117291d 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -155,9 +155,10 @@ class TestObjectRef(DataTestCase): self.session.commit() self.assertIsNotNone(person.uuid) with patch.object(mod.ObjectRef, 'model_class', new=model.Person): - typ = mod.ObjectRef(self.request, session=self.session) - value = typ.deserialize(node, person.uuid) - self.assertIs(value, person) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.ObjectRef(self.request) + value = typ.deserialize(node, person.uuid) + self.assertIs(value, person) def test_dictify(self): model = self.app.model @@ -181,42 +182,46 @@ class TestObjectRef(DataTestCase): value = typ.objectify(None) self.assertIsNone(value) - # model instance - person = model.Person(full_name="Betty Boop") - self.session.add(person) - self.session.commit() - self.assertIsNotNone(person.uuid) - with patch.object(mod.ObjectRef, 'model_class', new=model.Person): + with patch.object(mod, 'Session', return_value=self.session): - # can specify as uuid - typ = mod.ObjectRef(self.request, session=self.session) - value = typ.objectify(person.uuid) - self.assertIs(value, person) + # model instance + person = model.Person(full_name="Betty Boop") + self.session.add(person) + self.session.commit() + self.assertIsNotNone(person.uuid) + with patch.object(mod.ObjectRef, 'model_class', new=model.Person): - # or can specify object proper - typ = mod.ObjectRef(self.request, session=self.session) - value = typ.objectify(person) - self.assertIs(value, person) + # can specify as uuid + typ = mod.ObjectRef(self.request) + value = typ.objectify(person.uuid) + self.assertIs(value, person) - # error if not found - with patch.object(mod.ObjectRef, 'model_class', new=model.Person): - typ = mod.ObjectRef(self.request, session=self.session) - self.assertRaises(ValueError, typ.objectify, 'WRONG-UUID') + # or can specify object proper + typ = mod.ObjectRef(self.request) + value = typ.objectify(person) + self.assertIs(value, person) + + # error if not found + with patch.object(mod.ObjectRef, 'model_class', new=model.Person): + typ = mod.ObjectRef(self.request) + self.assertRaises(ValueError, typ.objectify, 'WRONG-UUID') def test_get_query(self): model = self.app.model with patch.object(mod.ObjectRef, 'model_class', new=model.Person): - typ = mod.ObjectRef(self.request, session=self.session) - query = typ.get_query() - self.assertIsInstance(query, orm.Query) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.ObjectRef(self.request) + query = typ.get_query() + self.assertIsInstance(query, orm.Query) def test_sort_query(self): model = self.app.model with patch.object(mod.ObjectRef, 'model_class', new=model.Person): - typ = mod.ObjectRef(self.request, session=self.session) - query = typ.get_query() - sorted_query = typ.sort_query(query) - self.assertIs(sorted_query, query) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.ObjectRef(self.request) + query = typ.get_query() + sorted_query = typ.sort_query(query) + self.assertIs(sorted_query, query) def test_widget_maker(self): model = self.app.model @@ -226,90 +231,98 @@ class TestObjectRef(DataTestCase): # basic with patch.object(mod.ObjectRef, 'model_class', new=model.Person): - typ = mod.ObjectRef(self.request, session=self.session) - widget = typ.widget_maker() - self.assertEqual(len(widget.values), 1) - self.assertEqual(widget.values[0][1], "Betty Boop") + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.ObjectRef(self.request) + widget = typ.widget_maker() + self.assertEqual(len(widget.values), 1) + self.assertEqual(widget.values[0][1], "Betty Boop") # empty option with patch.object(mod.ObjectRef, 'model_class', new=model.Person): - typ = mod.ObjectRef(self.request, session=self.session, empty_option=True) - widget = typ.widget_maker() - self.assertEqual(len(widget.values), 2) - self.assertEqual(widget.values[0][1], "(none)") - self.assertEqual(widget.values[1][1], "Betty Boop") + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.ObjectRef(self.request, empty_option=True) + widget = typ.widget_maker() + self.assertEqual(len(widget.values), 2) + self.assertEqual(widget.values[0][1], "(none)") + self.assertEqual(widget.values[1][1], "Betty Boop") class TestPersonRef(WebTestCase): def test_sort_query(self): - typ = mod.PersonRef(self.request, session=self.session) - query = typ.get_query() - self.assertIsInstance(query, orm.Query) - sorted_query = typ.sort_query(query) - self.assertIsInstance(sorted_query, orm.Query) - self.assertIsNot(sorted_query, query) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.PersonRef(self.request) + query = typ.get_query() + self.assertIsInstance(query, orm.Query) + sorted_query = typ.sort_query(query) + self.assertIsInstance(sorted_query, orm.Query) + self.assertIsNot(sorted_query, query) def test_get_object_url(self): self.pyramid_config.add_route('people.view', '/people/{uuid}') model = self.app.model - typ = mod.PersonRef(self.request, session=self.session) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.PersonRef(self.request) - person = model.Person(full_name="Barney Rubble") - self.session.add(person) - self.session.commit() + person = model.Person(full_name="Barney Rubble") + self.session.add(person) + self.session.commit() - url = typ.get_object_url(person) - self.assertIsNotNone(url) - self.assertIn(f'/people/{person.uuid}', url) + url = typ.get_object_url(person) + self.assertIsNotNone(url) + self.assertIn(f'/people/{person.uuid}', url) class TestRoleRef(WebTestCase): def test_sort_query(self): - typ = mod.RoleRef(self.request, session=self.session) - query = typ.get_query() - self.assertIsInstance(query, orm.Query) - sorted_query = typ.sort_query(query) - self.assertIsInstance(sorted_query, orm.Query) - self.assertIsNot(sorted_query, query) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.RoleRef(self.request) + query = typ.get_query() + self.assertIsInstance(query, orm.Query) + sorted_query = typ.sort_query(query) + self.assertIsInstance(sorted_query, orm.Query) + self.assertIsNot(sorted_query, query) def test_get_object_url(self): self.pyramid_config.add_route('roles.view', '/roles/{uuid}') model = self.app.model - typ = mod.RoleRef(self.request, session=self.session) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.RoleRef(self.request) - role = model.Role(name='Manager') - self.session.add(role) - self.session.commit() + role = model.Role(name='Manager') + self.session.add(role) + self.session.commit() - url = typ.get_object_url(role) - self.assertIsNotNone(url) - self.assertIn(f'/roles/{role.uuid}', url) + url = typ.get_object_url(role) + self.assertIsNotNone(url) + self.assertIn(f'/roles/{role.uuid}', url) class TestUserRef(WebTestCase): def test_sort_query(self): - typ = mod.UserRef(self.request, session=self.session) - query = typ.get_query() - self.assertIsInstance(query, orm.Query) - sorted_query = typ.sort_query(query) - self.assertIsInstance(sorted_query, orm.Query) - self.assertIsNot(sorted_query, query) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.UserRef(self.request) + query = typ.get_query() + self.assertIsInstance(query, orm.Query) + sorted_query = typ.sort_query(query) + self.assertIsInstance(sorted_query, orm.Query) + self.assertIsNot(sorted_query, query) def test_get_object_url(self): self.pyramid_config.add_route('users.view', '/users/{uuid}') model = self.app.model - typ = mod.UserRef(self.request, session=self.session) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.UserRef(self.request) - user = model.User(username='barney') - self.session.add(user) - self.session.commit() + user = model.User(username='barney') + self.session.add(user) + self.session.commit() - url = typ.get_object_url(user) - self.assertIsNotNone(url) - self.assertIn(f'/users/{user.uuid}', url) + url = typ.get_object_url(user) + self.assertIsNotNone(url) + self.assertIn(f'/users/{user.uuid}', url) class TestUserRefs(DataTestCase): @@ -320,9 +333,10 @@ class TestUserRefs(DataTestCase): def test_widget_maker(self): model = self.app.model - typ = mod.UserRefs(self.request, session=self.session) - widget = typ.widget_maker() - self.assertIsInstance(widget, widgets.UserRefsWidget) + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.UserRefs(self.request) + widget = typ.widget_maker() + self.assertIsInstance(widget, widgets.UserRefsWidget) class TestRoleRefs(DataTestCase): @@ -341,20 +355,22 @@ class TestRoleRefs(DataTestCase): self.session.add(blokes) self.session.commit() - # with root access, default values include: admin, blokes - self.request.is_root = True - typ = mod.RoleRefs(self.request, session=self.session) - widget = typ.widget_maker() - self.assertEqual(len(widget.values), 2) - self.assertEqual(widget.values[0][1], "Administrator") - self.assertEqual(widget.values[1][1], "Blokes") + with patch.object(mod, 'Session', return_value=self.session): - # without root, default values include: blokes - self.request.is_root = False - typ = mod.RoleRefs(self.request, session=self.session) - widget = typ.widget_maker() - self.assertEqual(len(widget.values), 1) - self.assertEqual(widget.values[0][1], "Blokes") + # with root access, default values include: admin, blokes + self.request.is_root = True + typ = mod.RoleRefs(self.request) + widget = typ.widget_maker() + self.assertEqual(len(widget.values), 2) + self.assertEqual(widget.values[0][1], "Administrator") + self.assertEqual(widget.values[1][1], "Blokes") + + # without root, default values include: blokes + self.request.is_root = False + typ = mod.RoleRefs(self.request) + widget = typ.widget_maker() + self.assertEqual(len(widget.values), 1) + self.assertEqual(widget.values[0][1], "Blokes") class TestPermissions(DataTestCase): diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index f98210c..3e07902 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -10,6 +10,7 @@ from pyramid import testing from wuttaweb import grids from wuttaweb.forms import widgets as mod +from wuttaweb.forms import schema from wuttaweb.forms.schema import (FileDownload, PersonRef, RoleRefs, UserRefs, Permissions, WuttaDateTime, EmailRecipients) from tests.util import WebTestCase @@ -32,31 +33,33 @@ class TestObjectRefWidget(WebTestCase): self.session.add(person) self.session.commit() - # standard (editable) - node = colander.SchemaNode(PersonRef(self.request, session=self.session)) - widget = self.make_widget() - field = self.make_field(node) - html = widget.serialize(field, person.uuid) - self.assertIn('') + # empty + html = widget.serialize(field, set(), readonly=True) + self.assertEqual(html, '') - # with data, no actions - user = model.User(username='barney') - self.session.add(user) - self.session.commit() - html = widget.serialize(field, {user.uuid}, readonly=True) - self.assertIn(' Date: Thu, 2 Jan 2025 21:28:55 -0600 Subject: [PATCH 04/11] fix: add WuttaQuantity schema type, widget --- src/wuttaweb/forms/schema.py | 21 ++++++++++++++++++++ src/wuttaweb/forms/widgets.py | 36 +++++++++++++++++++++++++++++++++++ tests/forms/test_schema.py | 9 +++++++++ tests/forms/test_widgets.py | 32 ++++++++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index a7f5de2..0dabf64 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -177,6 +177,27 @@ class WuttaMoney(colander.Money): return widgets.WuttaMoneyInputWidget(self.request, **kwargs) +class WuttaQuantity(colander.Decimal): + """ + Custom schema type for "quantity" fields. + + This is a subclass of :class:`colander:colander.Decimal` but uses + :class:`~wuttaweb.forms.widgets.WuttaQuantityWidget` by default. + + :param request: Current :term:`request` object. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + + def widget_maker(self, **kwargs): + """ """ + return widgets.WuttaQuantityWidget(self.request, **kwargs) + + class WuttaSet(colander.Set): """ Custom schema type for :class:`python:set` fields. diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 7b3e4be..a6f33d2 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -226,6 +226,42 @@ class WuttaMoneyInputWidget(MoneyInputWidget): return super().serialize(field, cstruct, **kw) +class WuttaQuantityWidget(TextInputWidget): + """ + Custom widget for "quantity" fields. This is used by default for + :class:`~wuttaweb.forms.schema.WuttaQuantity` type nodes. + + The main purpose of this widget is to leverage + :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()` + for the readonly display. + + This is a subclass of + :class:`deform:deform.widget.TextInputWidget` and uses these + Deform templates: + + * ``textinput`` + + :param request: Current :term:`request` object. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + + def serialize(self, field, cstruct, **kw): + """ """ + readonly = kw.get('readonly', self.readonly) + if readonly: + if cstruct in (colander.null, None): + return HTML.tag('span') + cstruct = decimal.Decimal(cstruct) + return HTML.tag('span', c=[self.app.render_quantity(cstruct)]) + + return super().serialize(field, cstruct, **kw) + + class FileDownloadWidget(Widget): """ Widget for use with :class:`~wuttaweb.forms.schema.FileDownload` diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 117291d..4dfc962 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -89,6 +89,15 @@ class TestWuttaMoney(WebTestCase): self.assertIsInstance(widget, widgets.WuttaMoneyInputWidget) +class TestWuttaQuantity(WebTestCase): + + def test_widget_maker(self): + enum = self.app.enum + typ = mod.WuttaQuantity(self.request) + widget = typ.widget_maker() + self.assertIsInstance(widget, widgets.WuttaQuantityWidget) + + class TestObjectRef(DataTestCase): def setUp(self): diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index 3e07902..7752dfe 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -125,7 +125,7 @@ class TestWuttaMoneyInputWidget(WebTestCase): return mod.WuttaMoneyInputWidget(self.request, **kwargs) def test_serialize(self): - node = colander.SchemaNode(WuttaDateTime()) + node = colander.SchemaNode(schema.WuttaMoney(self.request)) field = self.make_field(node) widget = self.make_widget() amount = decimal.Decimal('12.34') @@ -143,6 +143,36 @@ class TestWuttaMoneyInputWidget(WebTestCase): self.assertEqual(result, '') +class TestWuttaQuantityWidget(WebTestCase): + + def make_field(self, node, **kwargs): + # TODO: not sure why default renderer is in use even though + # pyramid_deform was included in setup? but this works.. + kwargs.setdefault('renderer', deform.Form.default_renderer) + return deform.Field(node, **kwargs) + + def make_widget(self, **kwargs): + return mod.WuttaQuantityWidget(self.request, **kwargs) + + def test_serialize(self): + node = colander.SchemaNode(schema.WuttaQuantity(self.request)) + field = self.make_field(node) + widget = self.make_widget() + amount = decimal.Decimal('42.00') + + # editable widget has normal text input + result = widget.serialize(field, str(amount)) + self.assertIn('42') + + # readonly w/ null value + result = widget.serialize(field, None, readonly=True) + self.assertEqual(result, '') + + class TestFileDownloadWidget(WebTestCase): def make_field(self, node, **kwargs): From 170afe650bc578de2289edaedbc00c3b66a85427 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 2 Jan 2025 22:35:43 -0600 Subject: [PATCH 05/11] fix: add "xref buttons" tool panel for master view also add `url` param for `MasterView.make_button()` --- src/wuttaweb/templates/master/view.mako | 15 ++++++++ src/wuttaweb/views/master.py | 50 ++++++++++++++++++++++++- tests/views/test_master.py | 6 +++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako index b4db013..7d189ef 100644 --- a/src/wuttaweb/templates/master/view.mako +++ b/src/wuttaweb/templates/master/view.mako @@ -33,6 +33,21 @@ % endif +<%def name="tool_panels()"> + ${parent.tool_panels()} + ${self.tool_panel_xref()} + + +<%def name="tool_panel_xref()"> + % if xref_buttons: + + % for button in xref_buttons: + ${button} + % endfor + + % endif + + <%def name="render_vue_templates()"> ${parent.render_vue_templates()} % if master.has_rows: diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 0030859..ec7cdf8 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -601,6 +601,8 @@ class MasterView(View): context['rows_grid'] = grid + context['xref_buttons'] = self.get_xref_buttons(obj) + return self.render_to_response('view', context) ############################## @@ -1569,6 +1571,7 @@ class MasterView(View): label, variant=None, primary=False, + url=None, **kwargs, ): """ @@ -1595,10 +1598,17 @@ class MasterView(View): avoids the Buefy vs. Oruga confusion, and the implementation can change in the future. + :param url: Specify this (instead of ``href``) to make the + button act like a link. This will yield something like: + ```` + :param \**kwargs: All remaining kwargs are passed to the underlying ``HTML.tag()`` call, so will be rendered as attributes on the button tag. + **NB.** You cannot specify a ``tag`` kwarg, for technical + reasons. + :returns: HTML literal for the button element. Will be something along the lines of: @@ -1620,7 +1630,45 @@ class MasterView(View): elif primary: btn_kw['type'] = 'is-primary' - return HTML.tag('b-button', **btn_kw) + if url: + btn_kw['href'] = url + + button = HTML.tag('b-button', **btn_kw) + + if url: + # nb. unfortunately HTML.tag() calls its first arg 'tag' + # and so we can't pass a kwarg with that name...so instead + # we patch that into place manually + button = str(button) + button = button.replace(' Date: Thu, 2 Jan 2025 22:52:32 -0600 Subject: [PATCH 06/11] fix: add basic support for row grid "view" action links still no actual "view row" support just yet, but subclass can implement however they like.. --- src/wuttaweb/views/master.py | 35 +++++++++++++++++++++++++++++++++++ tests/views/test_master.py | 12 ++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index ec7cdf8..3701980 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -372,6 +372,20 @@ class MasterView(View): List of columns for the row grid. This is optional; see also :meth:`get_row_grid_columns()`. + + This is optional; see also :meth:`get_row_grid_columns()`. + + .. attribute:: rows_viewable + + Boolean indicating whether the row model supports "viewing" - + i.e. it should have a "View" action in the row grid. + + (For now) If you enable this, you must also override + :meth:`get_row_action_url_view()`. + + .. note:: + This eventually will cause there to be a ``row_view`` route + to be configured as well. """ ############################## @@ -409,6 +423,7 @@ class MasterView(View): rows_sort_defaults = None rows_paginated = True rows_paginate_on_backend = True + rows_viewable = False # current action listing = False @@ -2414,6 +2429,16 @@ class MasterView(View): kwargs.setdefault('paginated', self.rows_paginated) kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend) + if 'actions' not in kwargs: + actions = [] + + if self.rows_viewable: + actions.append(self.make_grid_action('view', icon='eye', + url=self.get_row_action_url_view)) + + if actions: + kwargs['actions'] = actions + grid = self.make_grid(**kwargs) self.configure_row_grid(grid) grid.load_settings() @@ -2532,6 +2557,16 @@ class MasterView(View): labels.update(cls.row_labels) return labels + def get_row_action_url_view(self, row, i): + """ + Must return the "view" action url for the given row object. + + Only relevant if :attr:`rows_viewable` is true. + + There is no default logic; subclass must override if needed. + """ + raise NotImplementedError + ############################## # class methods ############################## diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 8e674eb..3d06f49 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1655,6 +1655,18 @@ class TestMasterView(WebTestCase): self.assertIsNone(grid.model_class) self.assertEqual(grid.data, []) + # view action + with patch.object(view, 'rows_viewable', new=True): + with patch.object(view, 'get_row_action_url_view', return_value='#'): + grid = view.make_row_model_grid(person, data=[]) + self.assertEqual(len(grid.actions), 1) + self.assertEqual(grid.actions[0].key, 'view') + + def test_get_row_action_url_view(self): + view = self.make_view() + row = MagicMock() + self.assertRaises(NotImplementedError, view.get_row_action_url_view, row, 0) + def test_get_rows_title(self): view = self.make_view() From 5cec585fdf7bbf761ff0056ff5696cac12138f67 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 2 Jan 2025 23:14:10 -0600 Subject: [PATCH 07/11] fix: improve rendering for batch row status --- src/wuttaweb/views/batch.py | 12 +++++++++++- tests/views/test_batch.py | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py index be44320..7ce3199 100644 --- a/src/wuttaweb/views/batch.py +++ b/src/wuttaweb/views/batch.py @@ -53,7 +53,7 @@ class BatchMasterView(MasterView): labels = { 'id': "Batch ID", - 'status_code': "Batch Status", + 'status_code': "Status", } sort_defaults = ('id', 'desc') @@ -62,6 +62,10 @@ class BatchMasterView(MasterView): rows_title = "Batch Rows" rows_sort_defaults = 'sequence' + row_labels = { + 'status_code': "Status", + } + def __init__(self, request, context=None): super().__init__(request, context=context) self.batch_handler = self.get_batch_handler() @@ -381,6 +385,12 @@ class BatchMasterView(MasterView): g.set_label('sequence', "Seq.", column_only=True) + g.set_renderer('status_code', self.render_row_status) + + def render_row_status(self, row, key, value): + """ """ + return row.STATUS.get(value, value) + ############################## # configuration ############################## diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py index a3a34fe..9b1db0f 100644 --- a/tests/views/test_batch.py +++ b/tests/views/test_batch.py @@ -367,6 +367,12 @@ class TestBatchMasterView(WebTestCase): self.assertIn('sequence', grid.labels) self.assertEqual(grid.labels['sequence'], "Seq.") + def test_render_row_status(self): + with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=None): + view = self.make_view() + row = MagicMock(foo=1, STATUS={1: 'bar'}) + self.assertEqual(view.render_row_status(row, 'foo', 1), 'bar') + def test_defaults(self): # nb. coverage only with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True): From 7895ce46764035f9c0a03ff91817903cec818262 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 6 Jan 2025 16:47:48 -0600 Subject: [PATCH 08/11] tests: move `WebTestCase` to `wuttaweb.testing` module --- src/wuttaweb/testing.py | 84 +++++++++++++++++++++++++++++++++++ tests/db/test_continuum.py | 3 +- tests/forms/test_schema.py | 2 +- tests/forms/test_widgets.py | 2 +- tests/grids/test_base.py | 2 +- tests/grids/test_filters.py | 2 +- tests/test_auth.py | 2 +- tests/test_handler.py | 2 +- tests/test_menus.py | 2 +- tests/util.py | 57 ------------------------ tests/views/test___init__.py | 2 +- tests/views/test_auth.py | 2 +- tests/views/test_base.py | 2 +- tests/views/test_batch.py | 2 +- tests/views/test_common.py | 2 +- tests/views/test_email.py | 2 +- tests/views/test_essential.py | 2 +- tests/views/test_master.py | 2 +- tests/views/test_people.py | 2 +- tests/views/test_progress.py | 2 +- tests/views/test_roles.py | 2 +- tests/views/test_settings.py | 2 +- tests/views/test_upgrades.py | 2 +- tests/views/test_users.py | 2 +- 24 files changed, 106 insertions(+), 80 deletions(-) create mode 100644 src/wuttaweb/testing.py diff --git a/src/wuttaweb/testing.py b/src/wuttaweb/testing.py new file mode 100644 index 0000000..0a1916b --- /dev/null +++ b/src/wuttaweb/testing.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +WuttaWeb - test utilities +""" + +from unittest.mock import MagicMock + +import fanstatic +from pyramid import testing + +from wuttjamaican.testing import DataTestCase + +from wuttaweb import subscribers + + +class WebTestCase(DataTestCase): + """ + Base class for test suites requiring a full (typical) web app. + """ + + def setUp(self): + self.setup_web() + + def setup_web(self): + self.setup_db() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + 'mako.directories': ['wuttaweb:templates'], + 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', + }) + + # init web + self.pyramid_config.include('pyramid_deform') + self.pyramid_config.include('pyramid_mako') + self.pyramid_config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + self.pyramid_config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render', + 'pyramid.events.BeforeRender') + self.pyramid_config.include('wuttaweb.static') + + # nb. mock out fanstatic env..good enough for now to avoid errors.. + needed = fanstatic.init_needed() + self.request.environ[fanstatic.NEEDED] = needed + + # setup new request w/ anonymous user + event = MagicMock(request=self.request) + subscribers.new_request(event) + def user_getter(request, **kwargs): pass + subscribers.new_request_set_user(event, db_session=self.session, + user_getter=user_getter) + + def tearDown(self): + self.teardown_web() + + def teardown_web(self): + testing.tearDown() + self.teardown_db() + + def make_request(self): + return testing.DummyRequest() diff --git a/tests/db/test_continuum.py b/tests/db/test_continuum.py index dbaa411..0503fd1 100644 --- a/tests/db/test_continuum.py +++ b/tests/db/test_continuum.py @@ -4,9 +4,8 @@ from unittest.mock import patch, MagicMock import pytest -from tests.util import WebTestCase - from wuttaweb.db import continuum as mod +from wuttaweb.testing import WebTestCase class TestWuttaWebContinuumPlugin(WebTestCase): diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 4dfc962..80a40e2 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -12,7 +12,7 @@ from sqlalchemy import orm from wuttjamaican.conf import WuttaConfig from wuttaweb.forms import schema as mod from wuttaweb.forms import widgets -from tests.util import DataTestCase, WebTestCase +from wuttaweb.testing import DataTestCase, WebTestCase class TestWutaDateTime(TestCase): diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index 7752dfe..47aed58 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -13,7 +13,7 @@ from wuttaweb.forms import widgets as mod from wuttaweb.forms import schema from wuttaweb.forms.schema import (FileDownload, PersonRef, RoleRefs, UserRefs, Permissions, WuttaDateTime, EmailRecipients) -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestObjectRefWidget(WebTestCase): diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 28478d2..4282554 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -16,7 +16,7 @@ from wuttaweb.grids import base as mod from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters from wuttaweb.util import FieldList from wuttaweb.forms import Form -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestGrid(WebTestCase): diff --git a/tests/grids/test_filters.py b/tests/grids/test_filters.py index c58d1af..bbc6611 100644 --- a/tests/grids/test_filters.py +++ b/tests/grids/test_filters.py @@ -9,7 +9,7 @@ import sqlalchemy as sa from wuttjamaican.db.model import Base from wuttaweb.grids import filters as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestGridFilter(WebTestCase): diff --git a/tests/test_auth.py b/tests/test_auth.py index f7705c8..a7dfe16 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -8,7 +8,7 @@ from pyramid import testing from wuttjamaican.conf import WuttaConfig from wuttaweb import auth as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestLoginUser(TestCase): diff --git a/tests/test_handler.py b/tests/test_handler.py index effb413..7c360fe 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -4,7 +4,7 @@ from wuttaweb import handler as mod, static from wuttaweb.forms import Form from wuttaweb.grids import Grid from wuttaweb.menus import MenuHandler -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestWebHandler(WebTestCase): diff --git a/tests/test_menus.py b/tests/test_menus.py index 84c4ee5..bca7bda 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -4,7 +4,7 @@ from unittest import TestCase from unittest.mock import patch, MagicMock from wuttaweb import menus as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestMenuHandler(WebTestCase): diff --git a/tests/util.py b/tests/util.py index e292253..e56d012 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,14 +1,7 @@ # -*- coding: utf-8; -*- -from unittest import TestCase -from unittest.mock import MagicMock - -import fanstatic -from pyramid import testing - from wuttjamaican.conf import WuttaConfig from wuttjamaican.testing import FileConfigTestCase -from wuttaweb import subscribers from wuttaweb.menus import MenuHandler @@ -39,56 +32,6 @@ class DataTestCase(FileConfigTestCase): self.teardown_files() -class WebTestCase(DataTestCase): - """ - Base class for test suites requiring a full (typical) web app. - """ - - def setUp(self): - self.setup_web() - - def setup_web(self): - self.setup_db() - self.request = self.make_request() - self.pyramid_config = testing.setUp(request=self.request, settings={ - 'wutta_config': self.config, - 'mako.directories': ['wuttaweb:templates'], - 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', - }) - - # init web - self.pyramid_config.include('pyramid_deform') - self.pyramid_config.include('pyramid_mako') - self.pyramid_config.add_directive('add_wutta_permission_group', - 'wuttaweb.auth.add_permission_group') - self.pyramid_config.add_directive('add_wutta_permission', - 'wuttaweb.auth.add_permission') - self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render', - 'pyramid.events.BeforeRender') - self.pyramid_config.include('wuttaweb.static') - - # nb. mock out fanstatic env..good enough for now to avoid errors.. - needed = fanstatic.init_needed() - self.request.environ[fanstatic.NEEDED] = needed - - # setup new request w/ anonymous user - event = MagicMock(request=self.request) - subscribers.new_request(event) - def user_getter(request, **kwargs): pass - subscribers.new_request_set_user(event, db_session=self.session, - user_getter=user_getter) - - def tearDown(self): - self.teardown_web() - - def teardown_web(self): - testing.tearDown() - self.teardown_db() - - def make_request(self): - return testing.DummyRequest() - - class NullMenuHandler(MenuHandler): """ Dummy menu handler for testing. diff --git a/tests/views/test___init__.py b/tests/views/test___init__.py index 6da63f0..1506a69 100644 --- a/tests/views/test___init__.py +++ b/tests/views/test___init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8; -*- -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestIncludeMe(WebTestCase): diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py index 5e7607f..c64ed3b 100644 --- a/tests/views/test_auth.py +++ b/tests/views/test_auth.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from pyramid.httpexceptions import HTTPFound, HTTPForbidden from wuttaweb.views import auth as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestAuthView(WebTestCase): diff --git a/tests/views/test_base.py b/tests/views/test_base.py index 9601212..4b3c4ab 100644 --- a/tests/views/test_base.py +++ b/tests/views/test_base.py @@ -5,7 +5,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound from wuttaweb.views import base as mod from wuttaweb.forms import Form from wuttaweb.grids import Grid, GridAction -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestView(WebTestCase): diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py index 9b1db0f..26e570a 100644 --- a/tests/views/test_batch.py +++ b/tests/views/test_batch.py @@ -10,7 +10,7 @@ from wuttjamaican.db import model from wuttjamaican.batch import BatchHandler from wuttaweb.views import MasterView, batch as mod from wuttaweb.progress import SessionProgress -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class MockBatch(model.BatchMixin, model.Base): diff --git a/tests/views/test_common.py b/tests/views/test_common.py index 4d86ba6..f889c00 100644 --- a/tests/views/test_common.py +++ b/tests/views/test_common.py @@ -5,7 +5,7 @@ from unittest.mock import patch import colander from wuttaweb.views import common as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestCommonView(WebTestCase): diff --git a/tests/views/test_email.py b/tests/views/test_email.py index a56bf66..7303f06 100644 --- a/tests/views/test_email.py +++ b/tests/views/test_email.py @@ -9,7 +9,7 @@ from pyramid.httpexceptions import HTTPNotFound from pyramid.response import Response from wuttaweb.views import email as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestEmailSettingViews(WebTestCase): diff --git a/tests/views/test_essential.py b/tests/views/test_essential.py index 9c3dc5d..afb29ba 100644 --- a/tests/views/test_essential.py +++ b/tests/views/test_essential.py @@ -1,7 +1,7 @@ # -*- coding: utf-8; -*- from wuttaweb.views import essential as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestEssentialViews(WebTestCase): diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 3d06f49..56c51c2 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -16,7 +16,7 @@ from wuttaweb.views import master as mod from wuttaweb.views import View from wuttaweb.progress import SessionProgress from wuttaweb.subscribers import new_request_set_user -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestMasterView(WebTestCase): diff --git a/tests/views/test_people.py b/tests/views/test_people.py index d3341f8..25abf9e 100644 --- a/tests/views/test_people.py +++ b/tests/views/test_people.py @@ -7,7 +7,7 @@ from sqlalchemy import orm from pyramid.httpexceptions import HTTPNotFound from wuttaweb.views import people -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestPersonView(WebTestCase): diff --git a/tests/views/test_progress.py b/tests/views/test_progress.py index 06a67f8..00edd69 100644 --- a/tests/views/test_progress.py +++ b/tests/views/test_progress.py @@ -4,7 +4,7 @@ from pyramid import testing from wuttaweb.views import progress as mod from wuttaweb.progress import get_progress_session -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestProgressView(WebTestCase): diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py index 22a30d2..909739e 100644 --- a/tests/views/test_roles.py +++ b/tests/views/test_roles.py @@ -8,7 +8,7 @@ import colander from wuttaweb.views import roles as mod from wuttaweb.forms.schema import RoleRef -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestRoleView(WebTestCase): diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py index c0810fa..ffc1fc8 100644 --- a/tests/views/test_settings.py +++ b/tests/views/test_settings.py @@ -6,7 +6,7 @@ import colander from pyramid.httpexceptions import HTTPNotFound from wuttaweb.views import settings as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestAppInfoView(WebTestCase): diff --git a/tests/views/test_upgrades.py b/tests/views/test_upgrades.py index 1a1d626..e468ce3 100644 --- a/tests/views/test_upgrades.py +++ b/tests/views/test_upgrades.py @@ -8,7 +8,7 @@ from unittest.mock import patch, MagicMock from wuttaweb.views import upgrades as mod from wuttjamaican.exc import ConfigurationError from wuttaweb.progress import get_progress_session -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestUpgradeView(WebTestCase): diff --git a/tests/views/test_users.py b/tests/views/test_users.py index ea49d49..1fa08f2 100644 --- a/tests/views/test_users.py +++ b/tests/views/test_users.py @@ -7,7 +7,7 @@ from sqlalchemy import orm import colander from wuttaweb.views import users as mod -from tests.util import WebTestCase +from wuttaweb.testing import WebTestCase class TestUserView(WebTestCase): From 2de08ad50d2f10efc081bdbf6a7dab89067d1e80 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 6 Jan 2025 16:48:42 -0600 Subject: [PATCH 09/11] fix: allow session injection for ObjectRef constructor for sake of simpler tests --- 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 0dabf64..3d0e08b 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -250,12 +250,13 @@ class ObjectRef(colander.SchemaType): *args, **kwargs, ): + # nb. allow session injection for tests + self.session = kwargs.pop('session', Session()) super().__init__(*args, **kwargs) self.request = request self.config = self.request.wutta_config self.app = self.config.get_app() self.model_instance = None - self.session = Session() if empty_option: if empty_option is True: From b3f1f8b6d9f08838ba826303be5fc6c2450d4837 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 6 Jan 2025 16:56:31 -0600 Subject: [PATCH 10/11] fix: improve built-in grid renderer logic - add `render_batch_id()` - allow kwargs for `render_currency()` - caller may specify built-in renderer w/ string identifier --- src/wuttaweb/grids/base.py | 47 ++++++++++++++++++++++++++++++++++---- tests/grids/test_base.py | 16 +++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 2259c3f..3a3d4f5 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -389,7 +389,6 @@ class Grid: self.key = key self.data = data self.labels = labels or {} - self.renderers = renderers or {} self.row_class = row_class self.actions = actions or [] self.linked_columns = linked_columns or [] @@ -399,6 +398,10 @@ class Grid: self.app = self.config.get_app() self.set_columns(columns or self.get_columns()) + self.renderers = {} + if renderers: + for key, val in renderers.items(): + self.set_renderer(key, val) self.set_default_renderers() self.set_tools(tools) @@ -593,8 +596,29 @@ class Grid: grid = Grid(request, columns=['foo', 'bar']) grid.set_renderer('foo', render_foo) + For convenience, in lieu of a renderer callable, you may + specify one of the following strings, which will be + interpreted as a built-in renderer callable, as shown below: + + * ``'batch_id'`` -> :meth:`render_batch_id()` + * ``'boolean'`` -> :meth:`render_boolean()` + * ``'currency'`` -> :meth:`render_currency()` + * ``'datetime'`` -> :meth:`render_datetime()` + * ``'quantity'`` -> :meth:`render_quantity()` + Renderer overrides are tracked via :attr:`renderers`. """ + builtins = { + 'batch_id': self.render_batch_id, + 'boolean': self.render_boolean, + 'currency': self.render_currency, + 'datetime': self.render_datetime, + 'quantity': self.render_quantity, + } + + if renderer in builtins: + renderer = builtins[renderer] + if kwargs: renderer = functools.partial(renderer, **kwargs) self.renderers[key] = renderer @@ -1759,6 +1783,20 @@ class Grid: # rendering methods ############################## + def render_batch_id(self, obj, key, value): + """ + Column renderer for batch ID values. + + This is not used automatically but you can use it explicitly:: + + grid.set_renderer('foo', 'batch_id') + """ + if value is None: + return "" + + batch_id = int(value) + return f'{batch_id:08d}' + def render_boolean(self, obj, key, value): """ Column renderer for boolean values. @@ -1774,7 +1812,7 @@ class Grid: """ return self.app.render_boolean(value) - def render_currency(self, obj, key, value): + def render_currency(self, obj, key, value, **kwargs): """ Column renderer for currency values. @@ -1784,9 +1822,10 @@ class Grid: This is not used automatically but you can use it explicitly:: - grid.set_renderer('foo', grid.render_currency) + grid.set_renderer('foo', 'currency') + grid.set_renderer('foo', 'currency', scale=4) """ - return self.app.render_currency(value) + return self.app.render_currency(value, **kwargs) def render_datetime(self, obj, key, value): """ diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 4282554..0cccbeb 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -208,6 +208,11 @@ class TestGrid(WebTestCase): self.assertIsNot(grid.renderers['foo'], render2) self.assertEqual(grid.renderers['foo'](None, None, None), 42) + # can use built-in string shortcut + grid.set_renderer('foo', 'quantity') + obj = MagicMock(foo=42.00) + self.assertEqual(grid.renderers['foo'](obj, 'foo', 42.00), '42') + def test_set_default_renderer(self): model = self.app.model @@ -1343,6 +1348,17 @@ class TestGrid(WebTestCase): # rendering methods ############################## + def test_render_batch_id(self): + grid = self.make_grid(columns=['foo', 'bar']) + + # null + obj = MagicMock(foo=None) + self.assertEqual(grid.render_batch_id(obj, 'foo', None), "") + + # int + obj = MagicMock(foo=42) + self.assertEqual(grid.render_batch_id(obj, 'foo', 42), "00000042") + def test_render_boolean(self): grid = self.make_grid(columns=['foo', 'bar']) From 49b13306c466061129e0d0a430d7d91d894160d7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 6 Jan 2025 17:00:27 -0600 Subject: [PATCH 11/11] =?UTF-8?q?bump:=20version=200.19.0=20=E2=86=92=200.?= =?UTF-8?q?19.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 20 ++++++++++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57338df..646afbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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.19.1 (2025-01-06) + +### Fix + +- improve built-in grid renderer logic +- allow session injection for ObjectRef constructor +- improve rendering for batch row status +- add basic support for row grid "view" action links +- add "xref buttons" tool panel for master view +- add WuttaQuantity schema type, widget +- remove `session` param from some form schema, widget classes +- add grid renderers for bool, currency, quantity +- use proper bulma styles for markdown content +- use span element for readonly money field widget render +- include grid filters for all column properties of model class +- use app handler to render error string, when progress fails +- add schema node type, widget for "money" (currency) fields +- exclude FK fields by default, for model forms +- fix style for header title text + ## v0.19.0 (2024-12-23) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 073ed88..f1bb921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.19.0" +version = "0.19.1" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -45,7 +45,7 @@ dependencies = [ "SQLAlchemy-Utils", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.19.1", + "WuttJamaican[db]>=0.19.2", "zope.sqlalchemy>=1.5", ]