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 import logging
from collections import OrderedDict from collections import OrderedDict
import sqlalchemy as sa
from sqlalchemy import orm
import colander import colander
import deform import deform
from colanderalchemy import SQLAlchemySchemaNode from colanderalchemy import SQLAlchemySchemaNode
@ -311,6 +314,7 @@ class Form:
self.model_class = type(self.model_instance) self.model_class = type(self.model_instance)
self.set_fields(fields or self.get_fields()) self.set_fields(fields or self.get_fields())
self.set_default_widgets()
# nb. this tracks grid JSON data for inclusion in page template # nb. this tracks grid JSON data for inclusion in page template
self.grid_vue_context = OrderedDict() self.grid_vue_context = OrderedDict()
@ -513,6 +517,38 @@ class Form:
if widget_type == 'notes': if widget_type == 'notes':
return widgets.NotesWidget(**kwargs) 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): def set_grid(self, key, grid):
""" """
Establish a :term:`grid` to be displayed for a field. This Establish a :term:`grid` to be displayed for a field. This

View file

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

View file

@ -36,9 +36,11 @@ in the namespace:
* :class:`deform:deform.widget.CheckboxWidget` * :class:`deform:deform.widget.CheckboxWidget`
* :class:`deform:deform.widget.SelectWidget` * :class:`deform:deform.widget.SelectWidget`
* :class:`deform:deform.widget.CheckboxChoiceWidget` * :class:`deform:deform.widget.CheckboxChoiceWidget`
* :class:`deform:deform.widget.DateTimeInputWidget`
* :class:`deform:deform.widget.MoneyInputWidget` * :class:`deform:deform.widget.MoneyInputWidget`
""" """
import datetime
import os import os
import colander import colander
@ -46,7 +48,7 @@ import humanize
from deform.widget import (Widget, TextInputWidget, TextAreaWidget, from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
PasswordWidget, CheckedPasswordWidget, PasswordWidget, CheckedPasswordWidget,
CheckboxWidget, SelectWidget, CheckboxChoiceWidget, CheckboxWidget, SelectWidget, CheckboxChoiceWidget,
MoneyInputWidget) DateTimeInputWidget, MoneyInputWidget)
from webhelpers2.html import HTML from webhelpers2.html import HTML
from wuttaweb.db import Session from wuttaweb.db import Session
@ -153,6 +155,43 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
self.session = session or Session() 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): class FileDownloadWidget(Widget):
""" """
Widget for use with :class:`~wuttaweb.forms.schema.FileDownload` Widget for use with :class:`~wuttaweb.forms.schema.FileDownload`

View file

@ -397,6 +397,7 @@ class Grid:
self.app = self.config.get_app() self.app = self.config.get_app()
self.set_columns(columns or self.get_columns()) self.set_columns(columns or self.get_columns())
self.set_default_renderers()
self.set_tools(tools) self.set_tools(tools)
# sorting # sorting
@ -596,6 +597,35 @@ class Grid:
renderer = functools.partial(renderer, **kwargs) renderer = functools.partial(renderer, **kwargs)
self.renderers[key] = renderer 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): def set_link(self, key, link=True):
""" """
Explicitly enable or disable auto-link behavior for a given Explicitly enable or disable auto-link behavior for a given
@ -1725,6 +1755,23 @@ class Grid:
# rendering methods # 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( def render_table_element(
self, self,
form=None, form=None,

View file

@ -162,6 +162,32 @@ class TestForm(TestCase):
widget = form.make_widget('fdajvdafjjf') widget = form.make_widget('fdajvdafjjf')
self.assertIsNone(widget) 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): def test_set_grid(self):
form = self.make_form(fields=['foo', 'bar']) form = self.make_form(fields=['foo', 'bar'])
self.assertNotIn('foo', form.widgets) self.assertNotIn('foo', form.widgets)

View file

@ -27,6 +27,12 @@ class TestWutaDateTime(TestCase):
result = typ.deserialize(node, '2024-12-11T10:33 PM') result = typ.deserialize(node, '2024-12-11T10:33 PM')
self.assertIsInstance(result, datetime.datetime) self.assertIsInstance(result, datetime.datetime)
self.assertEqual(result, datetime.datetime(2024, 12, 11, 22, 33)) 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') self.assertRaises(colander.Invalid, typ.deserialize, node, 'bogus')

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import datetime
from unittest.mock import patch from unittest.mock import patch
import colander import colander
@ -8,7 +9,8 @@ from pyramid import testing
from wuttaweb import grids from wuttaweb import grids
from wuttaweb.forms import widgets as mod 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 from tests.util import WebTestCase
@ -79,6 +81,32 @@ class TestObjectRefWidget(WebTestCase):
self.assertNotIn('url', values) 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): class TestFileDownloadWidget(WebTestCase):
def make_field(self, node, **kwargs): def make_field(self, node, **kwargs):

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import datetime
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -206,6 +207,31 @@ class TestGrid(WebTestCase):
self.assertIsNot(grid.renderers['foo'], render2) self.assertIsNot(grid.renderers['foo'], render2)
self.assertEqual(grid.renderers['foo'](None, None, None), 42) 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): def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, []) self.assertEqual(grid.linked_columns, [])
@ -1294,6 +1320,18 @@ class TestGrid(WebTestCase):
# rendering methods # 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): def test_render_vue_tag(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
html = grid.render_vue_tag() html = grid.render_vue_tag()