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> - <%def name="tool_panels()"> ${parent.tool_panels()} ${self.tool_panel_execution()} @@ -65,7 +52,7 @@
What will happen when this batch is executed?
-