fix: improve support for date, datetime fields in grids, forms
This commit is contained in:
		
							parent
							
								
									eda2326a97
								
							
						
					
					
						commit
						3cad7f1b13
					
				
					 8 changed files with 234 additions and 6 deletions
				
			
		| 
						 | 
				
			
			@ -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…
	
	Add table
		Add a link
		
	
		Reference in a new issue