fix: improve support for date, datetime fields in grids, forms
This commit is contained in:
parent
eda2326a97
commit
3cad7f1b13
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue