diff --git a/CHANGELOG.md b/CHANGELOG.md index 646afbd..57338df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,26 +5,6 @@ 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 f1bb921..073ed88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.19.1" +version = "0.19.0" 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.2", + "WuttJamaican[db]>=0.19.1", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 3d0e08b..7591a7a 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -177,41 +177,25 @@ 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`. + This is a subclass of :class:`colander.Set`, but adds + Wutta-related params to the constructor. :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): + def __init__(self, request, session=None): 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): @@ -247,16 +231,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: @@ -488,7 +472,7 @@ class RoleRefs(WuttaSet): :returns: Instance of :class:`~wuttaweb.forms.widgets.RoleRefsWidget`. """ - session = kwargs.setdefault('session', Session()) + kwargs.setdefault('session', self.session) if 'values' not in kwargs: model = self.app.model @@ -496,20 +480,20 @@ class RoleRefs(WuttaSet): # avoid built-ins which cannot be assigned to users avoid = { - auth.get_role_authenticated(session), - auth.get_role_anonymous(session), + auth.get_role_authenticated(self.session), + auth.get_role_anonymous(self.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(session).uuid) + avoid.add(auth.get_role_administrator(self.session).uuid) # everything else can be (un)assigned for users - roles = session.query(model.Role)\ - .filter(~model.Role.uuid.in_(avoid))\ - .order_by(model.Role.name)\ - .all() + roles = self.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 @@ -534,7 +518,7 @@ class UserRefs(WuttaSet): :returns: Instance of :class:`~wuttaweb.forms.widgets.UserRefsWidget`. """ - kwargs.setdefault('session', Session()) + kwargs.setdefault('session', self.session) return widgets.UserRefsWidget(self.request, **kwargs) @@ -564,7 +548,7 @@ class Permissions(WuttaSet): :returns: Instance of :class:`~wuttaweb.forms.widgets.PermissionsWidget`. """ - kwargs.setdefault('session', Session()) + kwargs.setdefault('session', self.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 a6f33d2..0af8bab 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -136,21 +136,26 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): Custom widget for :class:`python:set` fields. This is a subclass of - :class:`deform:deform.widget.CheckboxChoiceWidget`. + :class:`deform:deform.widget.CheckboxChoiceWidget`, but adds + Wutta-related params to the constructor. :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, *args, **kwargs): + def __init__(self, request, session=None, *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): @@ -226,42 +231,6 @@ 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 3a3d4f5..b9f0de7 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -116,8 +116,7 @@ class Grid: Dict of column (cell) value renderer overrides. - See also :meth:`set_renderer()` and - :meth:`set_default_renderers()`. + See also :meth:`set_renderer()`. .. attribute:: row_class @@ -389,6 +388,7 @@ 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,10 +398,6 @@ 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) @@ -596,29 +592,8 @@ 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 @@ -627,18 +602,15 @@ class Grid: """ Set default column value renderers, where applicable. - 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 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 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()` + 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()`. """ if not self.model_class: return @@ -654,8 +626,6 @@ 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): """ @@ -1783,80 +1753,23 @@ 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): """ - Column renderer for :class:`python:datetime.datetime` values. - - This calls + Default cell value renderer for + :class:`sqlalchemy:sqlalchemy.types.DateTime` columns, which + 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:: + :meth:`set_default_renderers()` or you can use it explicitly + for any :class:`python:datetime.datetime` column with:: 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 1305079..f03ab5b 100644 --- a/src/wuttaweb/templates/batch/view.mako +++ b/src/wuttaweb/templates/batch/view.mako @@ -1,6 +1,19 @@ ## -*- 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()} @@ -52,7 +65,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 7d189ef..b4db013 100644 --- a/src/wuttaweb/templates/master/view.mako +++ b/src/wuttaweb/templates/master/view.mako @@ -33,21 +33,6 @@ % 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 deleted file mode 100644 index 0a1916b..0000000 --- a/src/wuttaweb/testing.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- 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 7ce3199..be44320 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': "Status", + 'status_code': "Batch Status", } sort_defaults = ('id', 'desc') @@ -62,10 +62,6 @@ 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() @@ -385,12 +381,6 @@ 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 3701980..0030859 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -372,20 +372,6 @@ 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. """ ############################## @@ -423,7 +409,6 @@ class MasterView(View): rows_sort_defaults = None rows_paginated = True rows_paginate_on_backend = True - rows_viewable = False # current action listing = False @@ -616,8 +601,6 @@ class MasterView(View): context['rows_grid'] = grid - context['xref_buttons'] = self.get_xref_buttons(obj) - return self.render_to_response('view', context) ############################## @@ -1586,7 +1569,6 @@ class MasterView(View): label, variant=None, primary=False, - url=None, **kwargs, ): """ @@ -1613,17 +1595,10 @@ 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: @@ -1645,45 +1620,7 @@ class MasterView(View): elif primary: btn_kw['type'] = 'is-primary' - 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): @@ -265,29 +230,28 @@ class TestRoleRefsWidget(WebTestCase): self.session.commit() # nb. we let the field construct the widget via our type - with patch.object(schema, 'Session', return_value=self.session): - node = colander.SchemaNode(RoleRefs(self.request)) - field = self.make_field(node) - widget = field.widget + node = colander.SchemaNode(RoleRefs(self.request, session=self.session)) + 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)) - 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, 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) class TestUserRefsWidget(WebTestCase): @@ -302,37 +266,35 @@ 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)) - with patch.object(schema, 'Session', return_value=self.session): - node = colander.SchemaNode(UserRefs(self.request)) - field = self.make_field(node) - widget = field.widget + node = colander.SchemaNode(UserRefs(self.request, session=self.session)) + 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() + # 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('