diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 9caed35..f2b9e2c 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -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 diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 275d42c..bb49d1f 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -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): diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index b4ed6a3..2c8a944 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -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` diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 9bdd018..e01c5e1 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -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, diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index 8808bfa..914c9f7 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -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) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 003271c..9d6e70f 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -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') diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index 0ff5696..4e3a2e6 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -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('