diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index dda5290..37db3e9 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -31,7 +31,6 @@ import colander import sqlalchemy as sa from wuttjamaican.conf import parse_list -from wuttjamaican.util import localtime, make_utc from wuttaweb.db import Session from wuttaweb.forms import widgets @@ -53,18 +52,18 @@ class WuttaDateTime(colander.DateTime): if not appstruct: return colander.null - if appstruct.tzinfo is None: - appstruct = localtime(appstruct) + request = node.widget.request + config = request.wutta_config + app = config.get_app() + dt = app.localtime(appstruct) if self.format: - return appstruct.strftime(self.format) + return dt.strftime(self.format) + return dt.isoformat() - return appstruct.isoformat() - - def deserialize( # pylint: disable=inconsistent-return-statements,empty-docstring + def deserialize( # pylint: disable=inconsistent-return-statements self, node, cstruct ): - """ """ if not cstruct: return colander.null @@ -73,11 +72,16 @@ class WuttaDateTime(colander.DateTime): "%Y-%m-%dT%I:%M %p", ] + request = node.widget.request + config = request.wutta_config + app = config.get_app() + for fmt in formats: try: dt = datetime.datetime.strptime(cstruct, fmt) - dt = dt.astimezone() - return make_utc(dt) + if not dt.tzinfo: + dt = app.localtime(dt, from_utc=False) + return app.make_utc(dt) except Exception: # pylint: disable=broad-exception-caught pass diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 8dc6241..0ef6d87 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -252,7 +252,9 @@ class WuttaDateTimeWidget(DateTimeInputWidget): def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring """ """ readonly = kw.get("readonly", self.readonly) - if readonly and cstruct: + if readonly: + if not cstruct: + return "" dt = datetime.datetime.fromisoformat(cstruct) return self.app.render_datetime(dt) diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako index 556d76d..fc0d886 100644 --- a/src/wuttaweb/templates/appinfo/configure.mako +++ b/src/wuttaweb/templates/appinfo/configure.mako @@ -48,6 +48,16 @@ + + + + { + this.timezoneInvalid = response.data.invalid + this.timezoneChecking = false + }, response => { + this.timezoneError = response?.data?.error || "unknown error" + this.timezoneChecking = false + }) + } + } + + ThisPage.methods.timezoneValidate = function() { + if (this.timezoneChecking) { + return "Still checking time zone, please try again in a moment." + } + + if (this.timezoneError) { + return "Error checking time zone! Please reload page and try again." + } + + if (this.timezoneInvalid) { + return "The time zone is invalid!" + } + } + + ThisPageData.validators.push(ThisPage.methods.timezoneValidate) + ThisPageData.weblibs = ${json.dumps(weblibs or [])|n} ThisPageData.editWebLibraryShowDialog = false diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index ab49018..7f1abb5 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -19,6 +19,9 @@ ${app.get_node_title()} + + ${app.get_timezone_name()} + ${"Yes" if config.production() else "No"} diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 11cb0d0..d365da2 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -24,6 +24,7 @@ Views for app settings """ +import datetime import json import os import sys @@ -31,6 +32,7 @@ import subprocess from collections import OrderedDict from wuttjamaican.db.model import Setting +from wuttjamaican.util import get_timezone_by_name from wuttaweb.views import MasterView from wuttaweb.util import get_libver, get_liburl @@ -134,6 +136,7 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method {"name": f"{self.config.appname}.node_title"}, {"name": f"{self.config.appname}.production", "type": bool}, {"name": "wuttaweb.themes.expose_picker", "type": bool}, + {"name": f"{self.config.appname}.timezone.default"}, {"name": f"{self.config.appname}.web.menus.handler.spec"}, # nb. this is deprecated; we define so it is auto-deleted # when we replace with newer setting @@ -174,12 +177,31 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method return simple_settings + def configure_check_timezone(self): + """ + AJAX view to validate a user-specified timezone name. + + Route name for this is: ``appinfo.check_timezone`` + """ + tzname = self.request.GET.get("tzname") + if not tzname: + return {"invalid": "Must provide 'tzname' parameter."} + try: + get_timezone_by_name(tzname) + return {"invalid": False} + except Exception as err: # pylint: disable=broad-exception-caught + return {"invalid": str(err)} + def configure_get_context( # pylint: disable=empty-docstring,arguments-differ self, **kwargs ): """ """ context = super().configure_get_context(**kwargs) + # default system timezone + dt = datetime.datetime.now().astimezone() + context["default_timezone"] = dt.tzname() + # add registered menu handlers web = self.app.get_web_handler() handlers = web.get_menu_handler_specs() @@ -222,6 +244,32 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method return context + @classmethod + def defaults(cls, config): # pylint: disable=empty-docstring + """ """ + cls._defaults(config) + cls._appinfo_defaults(config) + + @classmethod + def _appinfo_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + + # check timezone + config.add_route( + f"{route_prefix}.check_timezone", + f"{url_prefix}/check-timezone", + request_method="GET", + ) + config.add_view( + cls, + attr="configure_check_timezone", + route_name=f"{route_prefix}.check_timezone", + permission=f"{permission_prefix}.configure", + renderer="json", + ) + class SettingView(MasterView): # pylint: disable=abstract-method """ diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index bfa54c1..a01ce35 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -11,88 +11,86 @@ from pyramid import testing from sqlalchemy import orm from wuttjamaican.conf import WuttaConfig +from wuttjamaican.util import get_timezone_by_name from wuttjamaican.testing import DataTestCase from wuttaweb.forms import schema as mod from wuttaweb.forms import widgets from wuttaweb.testing import WebTestCase -class TestWuttaDateTime(TestCase): +class TestWuttaDateTime(WebTestCase): def test_serialize(self): - typ = mod.WuttaDateTime() - node = colander.SchemaNode(typ) + tzlocal = get_timezone_by_name("America/Los_Angeles") + with patch.object(self.app, "get_timezone", return_value=tzlocal): + typ = mod.WuttaDateTime() + node = colander.SchemaNode( + typ, widget=widgets.WuttaDateTimeWidget(self.request) + ) - result = typ.serialize(node, colander.null) - self.assertIs(result, colander.null) + # null + self.assertIs(typ.serialize(node, colander.null), colander.null) + self.assertIs(typ.serialize(node, None), colander.null) + self.assertIs(typ.serialize(node, ""), colander.null) - result = typ.serialize(node, None) - self.assertIs(result, colander.null) + # naive, UTC + result = typ.serialize(node, datetime.datetime(2024, 12, 11, 22, 33)) + self.assertEqual(result, "2024-12-11T14:33:00-08:00") - result = typ.serialize(node, "") - self.assertIs(result, colander.null) + # aware, UTC + result = typ.serialize( + node, + datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc), + ) + self.assertEqual(result, "2024-12-11T14:33:00-08:00") - # naive, UTC - # TODO: must override local timezone for a complete test - result = typ.serialize(node, datetime.datetime(2024, 12, 11, 22, 33)) - self.assertTrue(result.startswith("2024-12-")) + # aware, local + result = typ.serialize( + node, + datetime.datetime(2024, 12, 11, 14, 33, tzinfo=tzlocal), + ) + self.assertEqual(result, "2024-12-11T14:33:00-08:00") - # aware, UTC - result = typ.serialize( - node, datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc) - ) - self.assertEqual(result, "2024-12-11T22:33:00+00:00") - - # aware, local - result = typ.serialize( - node, - datetime.datetime( - 2024, - 12, - 11, - 22, - 33, - tzinfo=datetime.timezone(-datetime.timedelta(hours=5)), - ), - ) - self.assertEqual(result, "2024-12-11T22:33:00-05:00") - - # custom format - typ = mod.WuttaDateTime(format="%Y-%m-%d %I:%M %p") - node = colander.SchemaNode(typ) - result = typ.serialize( - node, datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc) - ) - self.assertEqual(result, "2024-12-11 10:33 PM") + # custom format + typ = mod.WuttaDateTime(format="%Y-%m-%d %I:%M %p") + node = colander.SchemaNode( + typ, widget=widgets.WuttaDateTimeWidget(self.request) + ) + result = typ.serialize( + node, + datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc), + ) + self.assertEqual(result, "2024-12-11 02:33 PM") def test_deserialize(self): - typ = mod.WuttaDateTime() - node = colander.SchemaNode(typ) + tzlocal = get_timezone_by_name("America/Los_Angeles") + with patch.object(self.app, "get_timezone", return_value=tzlocal): + typ = mod.WuttaDateTime() + node = colander.SchemaNode( + typ, widget=widgets.WuttaDateTimeWidget(self.request) + ) - result = typ.deserialize(node, colander.null) - self.assertIs(result, colander.null) + # null + self.assertIs(typ.deserialize(node, colander.null), colander.null) + self.assertIs(typ.deserialize(node, None), colander.null) + self.assertIs(typ.deserialize(node, ""), colander.null) - result = typ.deserialize(node, None) - self.assertIs(result, colander.null) + # format #1 + result = typ.deserialize(node, "2024-12-11T22:33:00") + self.assertIsInstance(result, datetime.datetime) + self.assertEqual( + result, datetime.datetime(2024, 12, 12, 6, 33, tzinfo=None) + ) - result = typ.deserialize(node, "") - self.assertIs(result, colander.null) + # format #2 + result = typ.deserialize(node, "2024-12-11T10:33 PM") + self.assertIsInstance(result, datetime.datetime) + self.assertEqual( + result, datetime.datetime(2024, 12, 12, 6, 33, tzinfo=None) + ) - # TODO: must override local timezone for a complete test - result = typ.deserialize(node, "2024-12-11T10:33 PM") - self.assertIsInstance(result, datetime.datetime) - dt = datetime.datetime(2024, 12, 11, 22, 33) - self.assertLess(abs((result - dt).total_seconds()), 60 * 60 * 24) - self.assertIsNone(result.tzinfo) - - # TODO: must override local timezone for a complete test - result = typ.deserialize(node, "2024-12-11T22:33:00") - self.assertIsInstance(result, datetime.datetime) - dt = datetime.datetime(2024, 12, 11, 22, 33) - self.assertLess(abs((result - dt).total_seconds()), 60 * 60 * 24) - self.assertIsNone(result.tzinfo) - - self.assertRaises(colander.Invalid, typ.deserialize, node, "bogus") + # invalid + self.assertRaises(colander.Invalid, typ.deserialize, node, "bogus") class TestObjectNode(DataTestCase): diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index 22c4f00..d3b17aa 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -8,6 +8,7 @@ import colander import deform from pyramid import testing +from wuttjamaican.util import get_timezone_by_name from wuttaweb import grids from wuttaweb.forms import widgets as mod from wuttaweb.forms import schema @@ -145,19 +146,36 @@ class TestWuttaDateTimeWidget(WebTestCase): 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) + def test_serialize_editable(self): + tzlocal = get_timezone_by_name("America/New_York") + with patch.object(self.app, "get_timezone", return_value=tzlocal): + widget = self.make_widget() + self.assertFalse(widget.readonly) + node = colander.SchemaNode(WuttaDateTime(), widget=widget) + field = self.make_field(node) - # editable widget has normal picker html - result = widget.serialize(field, str(dt)) - self.assertIn("