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("