From 7fcb331806237e362df565a104994b9e054538b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 16 Dec 2025 22:52:33 -0600 Subject: [PATCH] feat: add "complete" (sic) timezone support at least for now, this is enough to let admin define the global default timezone for app, and override system local timezone. eventually should support per-user timezone..some day.. --- src/wuttaweb/forms/schema.py | 24 ++-- src/wuttaweb/forms/widgets.py | 4 +- src/wuttaweb/templates/appinfo/configure.mako | 78 +++++++++++ src/wuttaweb/templates/appinfo/index.mako | 3 + src/wuttaweb/views/settings.py | 48 +++++++ tests/forms/test_schema.py | 124 +++++++++--------- tests/forms/test_widgets.py | 40 ++++-- tests/grids/test_base.py | 22 ++-- tests/views/test_master.py | 4 + tests/views/test_settings.py | 20 +++ 10 files changed, 274 insertions(+), 93 deletions(-) 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("