diff --git a/CHANGELOG.md b/CHANGELOG.md index dbba860..470d4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,6 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## v0.25.0 (2025-12-17) - -### Feat - -- add "complete" (sic) timezone support - -### Fix - -- add local timezone awareness for datetime fields - ## v0.24.0 (2025-12-15) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 1cd984b..337c2a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.25.0" +version = "0.24.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -44,7 +44,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.26.0", + "WuttJamaican[db]>=0.25.0", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 37db3e9..dda5290 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -31,6 +31,7 @@ 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 @@ -52,18 +53,18 @@ class WuttaDateTime(colander.DateTime): if not appstruct: return colander.null - request = node.widget.request - config = request.wutta_config - app = config.get_app() + if appstruct.tzinfo is None: + appstruct = localtime(appstruct) - dt = app.localtime(appstruct) if self.format: - return dt.strftime(self.format) - return dt.isoformat() + return appstruct.strftime(self.format) - def deserialize( # pylint: disable=inconsistent-return-statements + return appstruct.isoformat() + + def deserialize( # pylint: disable=inconsistent-return-statements,empty-docstring self, node, cstruct ): + """ """ if not cstruct: return colander.null @@ -72,16 +73,11 @@ 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) - if not dt.tzinfo: - dt = app.localtime(dt, from_utc=False) - return app.make_utc(dt) + dt = dt.astimezone() + return 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 0ef6d87..8dc6241 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -252,9 +252,7 @@ class WuttaDateTimeWidget(DateTimeInputWidget): def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring """ """ readonly = kw.get("readonly", self.readonly) - if readonly: - if not cstruct: - return "" + if readonly and cstruct: 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 fc0d886..556d76d 100644 --- a/src/wuttaweb/templates/appinfo/configure.mako +++ b/src/wuttaweb/templates/appinfo/configure.mako @@ -48,16 +48,6 @@ - - - - { - 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 7f1abb5..ab49018 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -19,9 +19,6 @@ ${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 d365da2..11cb0d0 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -24,7 +24,6 @@ Views for app settings """ -import datetime import json import os import sys @@ -32,7 +31,6 @@ 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 @@ -136,7 +134,6 @@ 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 @@ -177,31 +174,12 @@ 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() @@ -244,32 +222,6 @@ 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 a01ce35..bfa54c1 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -11,86 +11,88 @@ 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(WebTestCase): +class TestWuttaDateTime(TestCase): def test_serialize(self): - 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) - ) + typ = mod.WuttaDateTime() + node = colander.SchemaNode(typ) - # 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, colander.null) + 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, None) + 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") + result = typ.serialize(node, "") + self.assertIs(result, colander.null) - # 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") + # 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-")) - # 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") + # 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") def test_deserialize(self): - 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) - ) + typ = mod.WuttaDateTime() + node = colander.SchemaNode(typ) - # 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, colander.null) + 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, None) + 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) - ) + result = typ.deserialize(node, "") + self.assertIs(result, colander.null) - # invalid - self.assertRaises(colander.Invalid, typ.deserialize, node, "bogus") + # 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") class TestObjectNode(DataTestCase): diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index d3b17aa..22c4f00 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -8,7 +8,6 @@ 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 @@ -146,36 +145,19 @@ class TestWuttaDateTimeWidget(WebTestCase): def make_widget(self, **kwargs): return mod.WuttaDateTimeWidget(self.request, **kwargs) - 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) + 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) - # nb. input data (from schema type) is always "local, zone-aware, isoformat" - dt = datetime.datetime(2024, 12, 12, 13, 49, tzinfo=tzlocal) - result = widget.serialize(field, dt.isoformat()) - self.assertIn("