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", ] diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 7591a7a..3d0e08b 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -177,25 +177,41 @@ 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. - 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,16 +247,16 @@ class ObjectRef(colander.SchemaType): self, request, empty_option=None, - session=None, *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 or Session() if empty_option: if empty_option is True: @@ -472,7 +488,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 +496,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 +534,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 +564,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..a6f33d2 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): @@ -231,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/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index b9f0de7..3a3d4f5 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 @@ -388,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 [] @@ -398,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) @@ -592,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 @@ -602,15 +627,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 +654,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 +1783,80 @@ 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. + + 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, **kwargs): + """ + 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', 'currency') + grid.set_renderer('foo', 'currency', scale=4) + """ + return self.app.render_currency(value, **kwargs) + 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/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')} 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/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/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/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 0030859..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 @@ -601,6 +616,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 +1586,7 @@ class MasterView(View): label, variant=None, primary=False, + url=None, **kwargs, ): """ @@ -1595,10 +1613,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 +1645,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('') +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): @@ -230,28 +265,29 @@ class TestRoleRefsWidget(WebTestCase): self.session.commit() # nb. we let the field construct the widget via our type - node = colander.SchemaNode(RoleRefs(self.request, session=self.session)) - field = self.make_field(node) - widget = field.widget + with patch.object(schema, 'Session', return_value=self.session): + node = colander.SchemaNode(RoleRefs(self.request)) + field = self.make_field(node) + widget = field.widget - # readonly values list includes admin - html = widget.serialize(field, {admin.uuid, blokes.uuid}, readonly=True) - self.assertIn(admin.name, html) - self.assertIn(blokes.name, html) + # readonly values list includes admin + html = widget.serialize(field, {admin.uuid, blokes.uuid}, readonly=True) + self.assertIn(admin.name, html) + self.assertIn(blokes.name, html) - # editable values list *excludes* admin (by default) - html = widget.serialize(field, {admin.uuid, blokes.uuid}) - self.assertNotIn(str(admin.uuid.hex), html) - self.assertIn(str(blokes.uuid.hex), html) + # editable values list *excludes* admin (by default) + html = widget.serialize(field, {admin.uuid, blokes.uuid}) + self.assertNotIn(str(admin.uuid.hex), html) + self.assertIn(str(blokes.uuid.hex), html) - # but admin is included for root user - self.request.is_root = True - node = colander.SchemaNode(RoleRefs(self.request, session=self.session)) - field = self.make_field(node) - widget = field.widget - html = widget.serialize(field, {admin.uuid, blokes.uuid}) - self.assertIn(str(admin.uuid.hex), html) - self.assertIn(str(blokes.uuid.hex), html) + # but admin is included for root user + self.request.is_root = True + node = colander.SchemaNode(RoleRefs(self.request)) + field = self.make_field(node) + widget = field.widget + html = widget.serialize(field, {admin.uuid, blokes.uuid}) + self.assertIn(str(admin.uuid.hex), html) + self.assertIn(str(blokes.uuid.hex), html) class TestUserRefsWidget(WebTestCase): @@ -266,35 +302,37 @@ class TestUserRefsWidget(WebTestCase): model = self.app.model # nb. we let the field construct the widget via our type - node = colander.SchemaNode(UserRefs(self.request, session=self.session)) - field = self.make_field(node) - widget = field.widget + # node = colander.SchemaNode(UserRefs(self.request, session=self.session)) + with patch.object(schema, 'Session', return_value=self.session): + node = colander.SchemaNode(UserRefs(self.request)) + field = self.make_field(node) + widget = field.widget - # readonly is required - self.assertRaises(NotImplementedError, widget.serialize, field, set()) - self.assertRaises(NotImplementedError, widget.serialize, field, set(), readonly=False) + # readonly is required + self.assertRaises(NotImplementedError, widget.serialize, field, set()) + self.assertRaises(NotImplementedError, widget.serialize, field, set(), readonly=False) - # empty - html = widget.serialize(field, set(), readonly=True) - self.assertEqual(html, '') + # 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('