diff --git a/pyproject.toml b/pyproject.toml index 0442f5b..073ed88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,9 +42,10 @@ dependencies = [ "pyramid_fanstatic", "pyramid_mako", "pyramid_tm", + "SQLAlchemy-Utils", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.19.0", + "WuttJamaican[db]>=0.19.1", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index b5e7667..7591a7a 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -155,6 +155,28 @@ class WuttaEnum(colander.Enum): return widgets.SelectWidget(**kwargs) +class WuttaMoney(colander.Money): + """ + Custom schema type for "money" fields. + + This is a subclass of :class:`colander:colander.Money`, but uses + the custom :class:`~wuttaweb.forms.widgets.WuttaMoneyInputWidget` + 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.WuttaMoneyInputWidget(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 0fa8773..2541195 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -41,6 +41,7 @@ in the namespace: """ import datetime +import decimal import os import colander @@ -194,6 +195,42 @@ class WuttaDateTimeWidget(DateTimeInputWidget): return super().serialize(field, cstruct, **kw) +class WuttaMoneyInputWidget(MoneyInputWidget): + """ + Custom widget for "money" fields. This is used by default for + :class:`~wuttaweb.forms.schema.WuttaMoney` type nodes. + + The main purpose of this widget is to leverage + :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()` + for the readonly display. + + This is a subclass of + :class:`deform:deform.widget.MoneyInputWidget` and uses these + Deform templates: + + * ``moneyinput`` + + :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 "" + cstruct = decimal.Decimal(cstruct) + return self.app.render_currency(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 ba402ce..b9f0de7 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -32,6 +32,7 @@ from collections import namedtuple, OrderedDict import sqlalchemy as sa from sqlalchemy import orm +from sqlalchemy_utils import get_columns import paginate from paginate_sqlalchemy import SqlalchemyOrmPage @@ -1116,19 +1117,16 @@ class Grid: filters = filters or {} if self.model_class: - # TODO: i tried using self.get_model_columns() here but in - # many cases that will be too aggressive. however it is - # often the case that the *grid* columns are a subset of - # the unerlying *table* columns. so until a better way - # is found, we choose "too few" instead of "too many" - # filters here. surely must improve it at some point. - for key in self.columns: - if key in filters: + # nb. i first tried self.get_model_columns() but my notes + # say that was too aggressive in many cases. then i tried + # using the *subset* of self.columns, just the ones which + # corresponded to a property on the model class. and now + # i am using sa-utils to give the "true" column list.. + for col in get_columns(self.model_class): + if col.key in filters: continue - prop = getattr(self.model_class, key, None) - if (prop and hasattr(prop, 'property') - and isinstance(prop.property, orm.ColumnProperty)): - filters[prop.key] = self.make_filter(prop) + prop = getattr(self.model_class, col.key) + filters[prop.key] = self.make_filter(prop) return filters diff --git a/src/wuttaweb/progress.py b/src/wuttaweb/progress.py index 759c2da..047be83 100644 --- a/src/wuttaweb/progress.py +++ b/src/wuttaweb/progress.py @@ -92,6 +92,9 @@ class SessionProgress(ProgressBase): """ def __init__(self, request, key, success_msg=None, success_url=None, error_url=None): + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() self.key = key self.success_msg = success_msg self.success_url = success_url @@ -137,7 +140,7 @@ class SessionProgress(ProgressBase): """ self.session.load() self.session['error'] = True - self.session['error_msg'] = str(error) + self.session['error_msg'] = self.app.render_error(error) self.session['error_url'] = error_url or self.error_url self.session.save() diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index 0697f03..f634c00 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -32,6 +32,7 @@ import uuid as _uuid import warnings import sqlalchemy as sa +from sqlalchemy import orm import colander from webhelpers2.html import HTML, tags @@ -478,24 +479,36 @@ def render_csrf_token(request, name='_csrf'): return HTML.tag('div', tags.hidden(name, value=token, id=None), style='display:none;') -def get_model_fields(config, model_class=None): +def get_model_fields(config, model_class, include_fk=False): """ Convenience function to return a list of field names for the given - model class. + :term:`data model` class. This logic only supports SQLAlchemy mapped classes and will use that to determine the field listing if applicable. Otherwise this returns ``None``. - """ - if not model_class: - return + :param config: App :term:`config object`. + + :param model_class: Data model class. + + :param include_fk: Whether to include foreign key column names in + the result. They are excluded by default, since the + relationship names are also included and generally preferred. + + :returns: List of field names, or ``None`` if it could not be + determined. + """ try: mapper = sa.inspect(model_class) except sa.exc.NoInspectionAvailable: return - fields = [prop.key for prop in mapper.iterate_properties] + if include_fk: + fields = [prop.key for prop in mapper.iterate_properties] + else: + fields = [prop.key for prop in mapper.iterate_properties + if not prop_is_fk(mapper, prop)] # nb. we never want the continuum 'versions' prop app = config.get_app() @@ -505,6 +518,20 @@ def get_model_fields(config, model_class=None): return fields +def prop_is_fk(mapper, prop): + """ """ + if not isinstance(prop, orm.ColumnProperty): + return False + + prop_columns = [col.name for col in prop.columns] + for rel in mapper.relationships: + rel_columns = [col.name for col in rel.local_columns] + if rel_columns == prop_columns: + return True + + return False + + def make_json_safe(value, key=None, warn=True): """ Convert a Python value as needed, to ensure it is compatible with diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 7b15660..564e8c2 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -80,6 +80,15 @@ class TestWuttaEnum(WebTestCase): self.assertIsInstance(widget, widgets.SelectWidget) +class TestWuttaMoney(WebTestCase): + + def test_widget_maker(self): + enum = self.app.enum + typ = mod.WuttaMoney(self.request) + widget = typ.widget_maker() + self.assertIsInstance(widget, widgets.WuttaMoneyInputWidget) + + class TestObjectRef(DataTestCase): def setUp(self): diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index e324458..71f0dc0 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -1,6 +1,7 @@ # -*- coding: utf-8; -*- import datetime +import decimal from unittest.mock import patch import colander @@ -107,6 +108,36 @@ class TestWuttaDateTimeWidget(WebTestCase): self.assertEqual(result, '2024-12-12 13:49+0000') +class TestWuttaMoneyInputWidget(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.WuttaMoneyInputWidget(self.request, **kwargs) + + def test_serialize(self): + node = colander.SchemaNode(WuttaDateTime()) + field = self.make_field(node) + widget = self.make_widget() + amount = decimal.Decimal('12.34') + + # editable widget has normal text input + result = widget.serialize(field, str(amount)) + self.assertIn('