3
0
Fork 0

fix: improve support for date, datetime fields in grids, forms

This commit is contained in:
Lance Edgar 2024-12-12 15:15:00 -06:00
parent eda2326a97
commit 3cad7f1b13
8 changed files with 234 additions and 6 deletions

View file

@ -27,6 +27,9 @@ Base form classes
import logging
from collections import OrderedDict
import sqlalchemy as sa
from sqlalchemy import orm
import colander
import deform
from colanderalchemy import SQLAlchemySchemaNode
@ -311,6 +314,7 @@ class Form:
self.model_class = type(self.model_instance)
self.set_fields(fields or self.get_fields())
self.set_default_widgets()
# nb. this tracks grid JSON data for inclusion in page template
self.grid_vue_context = OrderedDict()
@ -513,6 +517,38 @@ class Form:
if widget_type == 'notes':
return widgets.NotesWidget(**kwargs)
def set_default_widgets(self):
"""
Set default field widgets, where applicable.
This will add new entries to :attr:`widgets` for columns
whose data type implies a default widget should be used.
This is generally only possible if :attr:`model_class` is set
to a valid SQLAlchemy mapped class.
As of writing this only looks for
:class:`sqlalchemy:sqlalchemy.types.DateTime` fields and if
any are found, they are configured to use
:class:`~wuttaweb.forms.widgets.WuttaDateTimeWidget()`.
"""
from wuttaweb.forms import widgets
if not self.model_class:
return
for key in self.fields:
if key in self.widgets:
continue
attr = getattr(self.model_class, key, None)
if attr:
prop = getattr(attr, 'prop', None)
if prop and isinstance(prop, orm.ColumnProperty):
column = prop.columns[0]
if isinstance(column.type, sa.DateTime):
# self.set_renderer(key, self.render_datetime)
self.set_widget(key, widgets.WuttaDateTimeWidget(self.request))
def set_grid(self, key, grid):
"""
Establish a :term:`grid` to be displayed for a field. This

View file

@ -52,10 +52,18 @@ class WuttaDateTime(colander.DateTime):
if not cstruct:
return colander.null
try:
return datetime.datetime.strptime(cstruct, '%Y-%m-%dT%I:%M %p')
except:
node.raise_invalid("Invalid date and/or time")
formats = [
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%dT%I:%M %p',
]
for fmt in formats:
try:
return datetime.datetime.strptime(cstruct, fmt)
except:
pass
node.raise_invalid("Invalid date and/or time")
class ObjectNode(colander.SchemaNode):

View file

@ -36,9 +36,11 @@ in the namespace:
* :class:`deform:deform.widget.CheckboxWidget`
* :class:`deform:deform.widget.SelectWidget`
* :class:`deform:deform.widget.CheckboxChoiceWidget`
* :class:`deform:deform.widget.DateTimeInputWidget`
* :class:`deform:deform.widget.MoneyInputWidget`
"""
import datetime
import os
import colander
@ -46,7 +48,7 @@ import humanize
from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
PasswordWidget, CheckedPasswordWidget,
CheckboxWidget, SelectWidget, CheckboxChoiceWidget,
MoneyInputWidget)
DateTimeInputWidget, MoneyInputWidget)
from webhelpers2.html import HTML
from wuttaweb.db import Session
@ -153,6 +155,43 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
self.session = session or Session()
class WuttaDateTimeWidget(DateTimeInputWidget):
"""
Custom widget for :class:`python:datetime.datetime` fields.
The main purpose of this widget is to leverage
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
for the readonly display.
It is automatically used for SQLAlchemy mapped classes where the
field maps to a :class:`sqlalchemy:sqlalchemy.types.DateTime`
column. For other (non-mapped) datetime fields, you may have to
use it explicitly via
:meth:`~wuttaweb.forms.base.Form.set_widget()`.
This is a subclass of
:class:`deform:deform.widget.DateTimeInputWidget` and uses these
Deform templates:
* ``datetimeinput``
"""
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 and cstruct:
dt = datetime.datetime.fromisoformat(cstruct)
return self.app.render_datetime(dt)
return super().serialize(field, cstruct, **kw)
class FileDownloadWidget(Widget):
"""
Widget for use with :class:`~wuttaweb.forms.schema.FileDownload`

View file

@ -397,6 +397,7 @@ class Grid:
self.app = self.config.get_app()
self.set_columns(columns or self.get_columns())
self.set_default_renderers()
self.set_tools(tools)
# sorting
@ -596,6 +597,35 @@ class Grid:
renderer = functools.partial(renderer, **kwargs)
self.renderers[key] = renderer
def set_default_renderers(self):
"""
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 (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
for key in self.columns:
if key in self.renderers:
continue
attr = getattr(self.model_class, key, None)
if attr:
prop = getattr(attr, 'prop', None)
if prop and isinstance(prop, orm.ColumnProperty):
column = prop.columns[0]
if isinstance(column.type, sa.DateTime):
self.set_renderer(key, self.render_datetime)
def set_link(self, key, link=True):
"""
Explicitly enable or disable auto-link behavior for a given
@ -1725,6 +1755,23 @@ class Grid:
# rendering methods
##############################
def render_datetime(self, obj, key, value):
"""
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
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_table_element(
self,
form=None,

View file

@ -162,6 +162,32 @@ class TestForm(TestCase):
widget = form.make_widget('fdajvdafjjf')
self.assertIsNone(widget)
def test_set_default_widgets(self):
model = self.app.model
# no defaults for "plain" schema
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.widgets, {})
# no defaults for "plain" mapped class
form = self.make_form(model_class=model.Setting)
self.assertEqual(form.widgets, {})
class MyWidget(widgets.Widget):
pass
# widget set for datetime mapped field
form = self.make_form(model_class=model.Upgrade)
self.assertIn('created', form.widgets)
self.assertIsNot(form.widgets['created'], MyWidget)
self.assertNotIsInstance(form.widgets['created'], MyWidget)
# widget *not* set for datetime, if override present
form = self.make_form(model_class=model.Upgrade,
widgets={'created': MyWidget()})
self.assertIn('created', form.widgets)
self.assertIsInstance(form.widgets['created'], MyWidget)
def test_set_grid(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertNotIn('foo', form.widgets)

View file

@ -27,6 +27,12 @@ class TestWutaDateTime(TestCase):
result = typ.deserialize(node, '2024-12-11T10:33 PM')
self.assertIsInstance(result, datetime.datetime)
self.assertEqual(result, datetime.datetime(2024, 12, 11, 22, 33))
self.assertIsNone(result.tzinfo)
result = typ.deserialize(node, '2024-12-11T22:33:00')
self.assertIsInstance(result, datetime.datetime)
self.assertEqual(result, datetime.datetime(2024, 12, 11, 22, 33))
self.assertIsNone(result.tzinfo)
self.assertRaises(colander.Invalid, typ.deserialize, node, 'bogus')

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*-
import datetime
from unittest.mock import patch
import colander
@ -8,7 +9,8 @@ from pyramid import testing
from wuttaweb import grids
from wuttaweb.forms import widgets as mod
from wuttaweb.forms.schema import FileDownload, PersonRef, RoleRefs, UserRefs, Permissions
from wuttaweb.forms.schema import (FileDownload, PersonRef, RoleRefs, UserRefs, Permissions,
WuttaDateTime)
from tests.util import WebTestCase
@ -79,6 +81,32 @@ class TestObjectRefWidget(WebTestCase):
self.assertNotIn('url', values)
class TestWuttaDateTimeWidget(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.WuttaDateTimeWidget(self.request, **kwargs)
def test_serialize(self):
node = colander.SchemaNode(WuttaDateTime())
field = self.make_field(node)
widget = self.make_widget()
dt = datetime.datetime(2024, 12, 12, 13, 49, tzinfo=datetime.timezone.utc)
# editable widget has normal picker html
result = widget.serialize(field, str(dt))
self.assertIn('<wutta-datepicker', result)
# readonly is rendered per app convention
result = widget.serialize(field, str(dt), readonly=True)
self.assertEqual(result, '2024-12-12 13:49+0000')
class TestFileDownloadWidget(WebTestCase):
def make_field(self, node, **kwargs):

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*-
import datetime
from unittest import TestCase
from unittest.mock import patch, MagicMock
@ -206,6 +207,31 @@ class TestGrid(WebTestCase):
self.assertIsNot(grid.renderers['foo'], render2)
self.assertEqual(grid.renderers['foo'](None, None, None), 42)
def test_set_default_renderer(self):
model = self.app.model
# no defaults for "plain" schema
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.renderers, {})
# no defaults for "plain" mapped class
grid = self.make_grid(model_class=model.Setting)
self.assertEqual(grid.renderers, {})
def myrender(obj, key, value):
return value
# renderer set for datetime mapped field
grid = self.make_grid(model_class=model.Upgrade)
self.assertIn('created', grid.renderers)
self.assertIsNot(grid.renderers['created'], myrender)
# renderer *not* set for datetime, if override present
grid = self.make_grid(model_class=model.Upgrade,
renderers={'created': myrender})
self.assertIn('created', grid.renderers)
self.assertIs(grid.renderers['created'], myrender)
def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, [])
@ -1294,6 +1320,18 @@ class TestGrid(WebTestCase):
# rendering methods
##############################
def test_render_datetime(self):
grid = self.make_grid(columns=['foo', 'bar'])
obj = MagicMock(dt=None)
result = grid.render_datetime(obj, 'dt', None)
self.assertIsNone(result)
dt = datetime.datetime(2024, 12, 12, 13, 44, tzinfo=datetime.timezone.utc)
obj = MagicMock(dt=dt)
result = grid.render_datetime(obj, 'dt', str(dt))
self.assertEqual(result, '2024-12-12 13:44+0000')
def test_render_vue_tag(self):
grid = self.make_grid(columns=['foo', 'bar'])
html = grid.render_vue_tag()