3
0
Fork 0

Compare commits

...

4 commits

Author SHA1 Message Date
Lance Edgar 84ab931081 fix: include grid filters for all column properties of model class
by default anyway.  previous logic started from `grid.columns` and
then only included column properties, but now we start from the model
class itself and let sa-utils figure out the default list
2024-12-28 21:14:20 -06:00
Lance Edgar c2efc1cd1a fix: use app handler to render error string, when progress fails 2024-12-28 21:14:15 -06:00
Lance Edgar 171e9f7488 fix: add schema node type, widget for "money" (currency) fields 2024-12-28 20:33:56 -06:00
Lance Edgar c4fe90834e fix: exclude FK fields by default, for model forms
e.g. `person_uuid` and such
2024-12-28 18:56:04 -06:00
11 changed files with 181 additions and 28 deletions

View file

@ -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",
]

View file

@ -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.

View file

@ -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`

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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):

View file

@ -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('<b-input', result)
# readonly is rendered per app convention
result = widget.serialize(field, str(amount), readonly=True)
self.assertEqual(result, '$12.34')
# readonly w/ null value
result = widget.serialize(field, None, readonly=True)
self.assertEqual(result, '')
class TestFileDownloadWidget(WebTestCase):
def make_field(self, node, **kwargs):

View file

@ -982,6 +982,17 @@ class TestGrid(WebTestCase):
self.assertEqual(filters['value'], 42)
self.assertEqual(myfilters['value'], 42)
# filters for all *true* columns by default, despite grid.columns
with patch.object(mod.Grid, 'make_filter'):
# nb. filters are MagicMock instances
grid = self.make_grid(model_class=model.User,
columns=['username', 'person'])
filters = grid.make_backend_filters()
self.assertIn('username', filters)
self.assertIn('active', filters)
# nb. relationship not included by default
self.assertNotIn('person', filters)
def test_make_filter(self):
model = self.app.model

View file

@ -5,6 +5,8 @@ from unittest import TestCase
from pyramid import testing
from beaker.session import Session as BeakerSession
from wuttjamaican.testing import ConfigTestCase
from wuttaweb import progress as mod
@ -31,10 +33,11 @@ class TestGetProgressSession(TestCase):
self.assertEqual(session.id, 'mockid.progress.foo')
class TestSessionProgress(TestCase):
class TestSessionProgress(ConfigTestCase):
def setUp(self):
self.request = testing.DummyRequest()
self.setup_config()
self.request = testing.DummyRequest(wutta_config=self.config)
self.request.session.id = 'mockid'
def test_error_url(self):

View file

@ -11,6 +11,8 @@ from fanstatic import Library, Resource
from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttjamaican.testing import ConfigTestCase
from wuttaweb import util as mod
@ -463,14 +465,10 @@ class TestGetFormData(TestCase):
self.assertEqual(data, {'foo2': 'baz'})
class TestGetModelFields(TestCase):
def setUp(self):
self.config = WuttaConfig()
self.app = self.config.get_app()
class TestGetModelFields(ConfigTestCase):
def test_empty_model_class(self):
fields = mod.get_model_fields(self.config)
fields = mod.get_model_fields(self.config, None)
self.assertIsNone(fields)
def test_unknown_model_class(self):
@ -482,6 +480,19 @@ class TestGetModelFields(TestCase):
fields = mod.get_model_fields(self.config, model.Setting)
self.assertEqual(fields, ['name', 'value'])
def test_include_fk(self):
model = self.app.model
# fk excluded by default
fields = mod.get_model_fields(self.config, model.User)
self.assertNotIn('person_uuid', fields)
self.assertIn('person', fields)
# fk can be included
fields = mod.get_model_fields(self.config, model.User, include_fk=True)
self.assertIn('person_uuid', fields)
self.assertIn('person', fields)
def test_avoid_versions(self):
model = self.app.model