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
|
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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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`
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in a new issue