3
0
Fork 0

Compare commits

..

No commits in common. "652477bee02bd565ebe17e1a31f722de71b22792" and "286c683c9350a1f85c9ed6c3c0ecedaea7ce6803" have entirely different histories.

12 changed files with 95 additions and 286 deletions

View file

@ -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/) 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). 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) ## v0.24.0 (2025-12-15)
### Feat ### Feat

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaWeb" name = "WuttaWeb"
version = "0.25.0" version = "0.24.0"
description = "Web App for Wutta Framework" description = "Web App for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@ -44,7 +44,7 @@ dependencies = [
"pyramid_tm", "pyramid_tm",
"waitress", "waitress",
"WebHelpers2", "WebHelpers2",
"WuttJamaican[db]>=0.26.0", "WuttJamaican[db]>=0.25.0",
"zope.sqlalchemy>=1.5", "zope.sqlalchemy>=1.5",
] ]

View file

@ -31,6 +31,7 @@ import colander
import sqlalchemy as sa import sqlalchemy as sa
from wuttjamaican.conf import parse_list from wuttjamaican.conf import parse_list
from wuttjamaican.util import localtime, make_utc
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.forms import widgets from wuttaweb.forms import widgets
@ -52,18 +53,18 @@ class WuttaDateTime(colander.DateTime):
if not appstruct: if not appstruct:
return colander.null return colander.null
request = node.widget.request if appstruct.tzinfo is None:
config = request.wutta_config appstruct = localtime(appstruct)
app = config.get_app()
dt = app.localtime(appstruct)
if self.format: if self.format:
return dt.strftime(self.format) return appstruct.strftime(self.format)
return dt.isoformat()
def deserialize( # pylint: disable=inconsistent-return-statements return appstruct.isoformat()
def deserialize( # pylint: disable=inconsistent-return-statements,empty-docstring
self, node, cstruct self, node, cstruct
): ):
""" """
if not cstruct: if not cstruct:
return colander.null return colander.null
@ -72,16 +73,11 @@ class WuttaDateTime(colander.DateTime):
"%Y-%m-%dT%I:%M %p", "%Y-%m-%dT%I:%M %p",
] ]
request = node.widget.request
config = request.wutta_config
app = config.get_app()
for fmt in formats: for fmt in formats:
try: try:
dt = datetime.datetime.strptime(cstruct, fmt) dt = datetime.datetime.strptime(cstruct, fmt)
if not dt.tzinfo: dt = dt.astimezone()
dt = app.localtime(dt, from_utc=False) return make_utc(dt)
return app.make_utc(dt)
except Exception: # pylint: disable=broad-exception-caught except Exception: # pylint: disable=broad-exception-caught
pass pass

View file

@ -252,9 +252,7 @@ class WuttaDateTimeWidget(DateTimeInputWidget):
def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring
""" """ """ """
readonly = kw.get("readonly", self.readonly) readonly = kw.get("readonly", self.readonly)
if readonly: if readonly and cstruct:
if not cstruct:
return ""
dt = datetime.datetime.fromisoformat(cstruct) dt = datetime.datetime.fromisoformat(cstruct)
return self.app.render_datetime(dt) return self.app.render_datetime(dt)

View file

@ -48,16 +48,6 @@
</b-checkbox> </b-checkbox>
</b-field> </b-field>
<b-field label="Time Zone"
:message="timezoneFieldMessage"
:type="timezoneFieldType">
<b-input name="${app.appname}.timezone.default"
v-model="simpleSettings['${app.appname}.timezone.default']"
## TODO: ideally could use @change here but it does not work..?
##@change="timezoneCheck()"
@input="timezoneCheck(); settingsNeedSaved = true" />
</b-field>
<b-field label="Menu Handler"> <b-field label="Menu Handler">
<input type="hidden" <input type="hidden"
name="${app.appname}.web.menus.handler.spec" name="${app.appname}.web.menus.handler.spec"
@ -280,74 +270,6 @@
ThisPageData.menuHandlers = ${json.dumps(menu_handlers)|n} ThisPageData.menuHandlers = ${json.dumps(menu_handlers)|n}
ThisPageData.timezoneChecking = false
ThisPageData.timezoneInvalid = false
ThisPageData.timezoneError = false
ThisPage.computed.timezoneFieldMessage = function() {
if (this.timezoneChecking) {
return "Working, please wait..."
}
if (this.timezoneInvalid) {
return this.timezoneInvalid
}
if (this.timezoneError) {
return this.timezoneError
}
return "RESTART REQUIRED IF YOU CHANGE THIS. The system (default) timezone is: ${default_timezone}"
}
ThisPage.computed.timezoneFieldType = function() {
if (this.timezoneChecking) {
return 'is-warning'
}
if (this.timezoneInvalid || this.timezoneError) {
return 'is-danger'
}
}
ThisPage.methods.timezoneCheck = function() {
if (this.timezoneChecking) {
return
}
this.timezoneError = false
if (!this.simpleSettings['${config.appname}.timezone.default']) {
this.timezoneInvalid = false
} else {
this.timezoneChecking = true
const url = '${url(f"{route_prefix}.check_timezone")}'
const params = {
tzname: this.simpleSettings['${config.appname}.timezone.default'],
}
this.wuttaGET(url, params, response => {
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.weblibs = ${json.dumps(weblibs or [])|n}
ThisPageData.editWebLibraryShowDialog = false ThisPageData.editWebLibraryShowDialog = false

View file

@ -19,9 +19,6 @@
<b-field horizontal label="Node Title"> <b-field horizontal label="Node Title">
<span>${app.get_node_title()}</span> <span>${app.get_node_title()}</span>
</b-field> </b-field>
<b-field horizontal label="Time Zone">
<span>${app.get_timezone_name()}</span>
</b-field>
<b-field horizontal label="Production Mode"> <b-field horizontal label="Production Mode">
<span>${"Yes" if config.production() else "No"}</span> <span>${"Yes" if config.production() else "No"}</span>
</b-field> </b-field>

View file

@ -24,7 +24,6 @@
Views for app settings Views for app settings
""" """
import datetime
import json import json
import os import os
import sys import sys
@ -32,7 +31,6 @@ import subprocess
from collections import OrderedDict from collections import OrderedDict
from wuttjamaican.db.model import Setting from wuttjamaican.db.model import Setting
from wuttjamaican.util import get_timezone_by_name
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.util import get_libver, get_liburl 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}.node_title"},
{"name": f"{self.config.appname}.production", "type": bool}, {"name": f"{self.config.appname}.production", "type": bool},
{"name": "wuttaweb.themes.expose_picker", "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"}, {"name": f"{self.config.appname}.web.menus.handler.spec"},
# nb. this is deprecated; we define so it is auto-deleted # nb. this is deprecated; we define so it is auto-deleted
# when we replace with newer setting # when we replace with newer setting
@ -177,31 +174,12 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method
return simple_settings 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 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
self, **kwargs self, **kwargs
): ):
""" """ """ """
context = super().configure_get_context(**kwargs) context = super().configure_get_context(**kwargs)
# default system timezone
dt = datetime.datetime.now().astimezone()
context["default_timezone"] = dt.tzname()
# add registered menu handlers # add registered menu handlers
web = self.app.get_web_handler() web = self.app.get_web_handler()
handlers = web.get_menu_handler_specs() handlers = web.get_menu_handler_specs()
@ -244,32 +222,6 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method
return context 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 class SettingView(MasterView): # pylint: disable=abstract-method
""" """

View file

@ -11,86 +11,88 @@ from pyramid import testing
from sqlalchemy import orm from sqlalchemy import orm
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttjamaican.util import get_timezone_by_name
from wuttjamaican.testing import DataTestCase from wuttjamaican.testing import DataTestCase
from wuttaweb.forms import schema as mod from wuttaweb.forms import schema as mod
from wuttaweb.forms import widgets from wuttaweb.forms import widgets
from wuttaweb.testing import WebTestCase from wuttaweb.testing import WebTestCase
class TestWuttaDateTime(WebTestCase): class TestWuttaDateTime(TestCase):
def test_serialize(self): def test_serialize(self):
tzlocal = get_timezone_by_name("America/Los_Angeles") typ = mod.WuttaDateTime()
with patch.object(self.app, "get_timezone", return_value=tzlocal): node = colander.SchemaNode(typ)
typ = mod.WuttaDateTime()
node = colander.SchemaNode(
typ, widget=widgets.WuttaDateTimeWidget(self.request)
)
# null result = typ.serialize(node, colander.null)
self.assertIs(typ.serialize(node, colander.null), colander.null) self.assertIs(result, colander.null)
self.assertIs(typ.serialize(node, None), colander.null)
self.assertIs(typ.serialize(node, ""), colander.null)
# naive, UTC result = typ.serialize(node, None)
result = typ.serialize(node, datetime.datetime(2024, 12, 11, 22, 33)) self.assertIs(result, colander.null)
self.assertEqual(result, "2024-12-11T14:33:00-08:00")
# aware, UTC result = typ.serialize(node, "")
result = typ.serialize( self.assertIs(result, colander.null)
node,
datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc),
)
self.assertEqual(result, "2024-12-11T14:33:00-08:00")
# aware, local # naive, UTC
result = typ.serialize( # TODO: must override local timezone for a complete test
node, result = typ.serialize(node, datetime.datetime(2024, 12, 11, 22, 33))
datetime.datetime(2024, 12, 11, 14, 33, tzinfo=tzlocal), self.assertTrue(result.startswith("2024-12-"))
)
self.assertEqual(result, "2024-12-11T14:33:00-08:00")
# custom format # aware, UTC
typ = mod.WuttaDateTime(format="%Y-%m-%d %I:%M %p") result = typ.serialize(
node = colander.SchemaNode( node, datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc)
typ, widget=widgets.WuttaDateTimeWidget(self.request) )
) self.assertEqual(result, "2024-12-11T22:33:00+00:00")
result = typ.serialize(
node, # aware, local
datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc), result = typ.serialize(
) node,
self.assertEqual(result, "2024-12-11 02:33 PM") 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): def test_deserialize(self):
tzlocal = get_timezone_by_name("America/Los_Angeles") typ = mod.WuttaDateTime()
with patch.object(self.app, "get_timezone", return_value=tzlocal): node = colander.SchemaNode(typ)
typ = mod.WuttaDateTime()
node = colander.SchemaNode(
typ, widget=widgets.WuttaDateTimeWidget(self.request)
)
# null result = typ.deserialize(node, colander.null)
self.assertIs(typ.deserialize(node, colander.null), colander.null) self.assertIs(result, colander.null)
self.assertIs(typ.deserialize(node, None), colander.null)
self.assertIs(typ.deserialize(node, ""), colander.null)
# format #1 result = typ.deserialize(node, None)
result = typ.deserialize(node, "2024-12-11T22:33:00") self.assertIs(result, colander.null)
self.assertIsInstance(result, datetime.datetime)
self.assertEqual(
result, datetime.datetime(2024, 12, 12, 6, 33, tzinfo=None)
)
# format #2 result = typ.deserialize(node, "")
result = typ.deserialize(node, "2024-12-11T10:33 PM") self.assertIs(result, colander.null)
self.assertIsInstance(result, datetime.datetime)
self.assertEqual(
result, datetime.datetime(2024, 12, 12, 6, 33, tzinfo=None)
)
# invalid # TODO: must override local timezone for a complete test
self.assertRaises(colander.Invalid, typ.deserialize, node, "bogus") 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): class TestObjectNode(DataTestCase):

View file

@ -8,7 +8,6 @@ import colander
import deform import deform
from pyramid import testing from pyramid import testing
from wuttjamaican.util import get_timezone_by_name
from wuttaweb import grids from wuttaweb import grids
from wuttaweb.forms import widgets as mod from wuttaweb.forms import widgets as mod
from wuttaweb.forms import schema from wuttaweb.forms import schema
@ -146,36 +145,19 @@ class TestWuttaDateTimeWidget(WebTestCase):
def make_widget(self, **kwargs): def make_widget(self, **kwargs):
return mod.WuttaDateTimeWidget(self.request, **kwargs) return mod.WuttaDateTimeWidget(self.request, **kwargs)
def test_serialize_editable(self): def test_serialize(self):
tzlocal = get_timezone_by_name("America/New_York") node = colander.SchemaNode(WuttaDateTime())
with patch.object(self.app, "get_timezone", return_value=tzlocal): field = self.make_field(node)
widget = self.make_widget() widget = self.make_widget()
self.assertFalse(widget.readonly) dt = datetime.datetime(2024, 12, 12, 13, 49, tzinfo=datetime.timezone.utc)
node = colander.SchemaNode(WuttaDateTime(), widget=widget)
field = self.make_field(node)
# nb. input data (from schema type) is always "local, zone-aware, isoformat" # editable widget has normal picker html
dt = datetime.datetime(2024, 12, 12, 13, 49, tzinfo=tzlocal) result = widget.serialize(field, str(dt))
result = widget.serialize(field, dt.isoformat()) self.assertIn("<wutta-datepicker", result)
self.assertIn("<wutta-datepicker", result)
def test_serialize_readonly(self): # readonly is rendered per app convention
tzlocal = get_timezone_by_name("America/New_York") result = widget.serialize(field, str(dt), readonly=True)
with patch.object(self.app, "get_timezone", return_value=tzlocal): self.assertEqual(result, "2024-12-12 13:49+0000")
widget = self.make_widget(readonly=True)
self.assertTrue(widget.readonly)
node = colander.SchemaNode(WuttaDateTime(), widget=widget)
field = self.make_field(node)
# null
self.assertEqual(widget.serialize(field, colander.null), "")
self.assertEqual(widget.serialize(field, None), "")
self.assertEqual(widget.serialize(field, ""), "")
# 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.assertEqual(result, "2024-12-12 13:49-0500")
class TestWuttaMoneyInputWidget(WebTestCase): class TestWuttaMoneyInputWidget(WebTestCase):

View file

@ -14,7 +14,6 @@ from paginate_sqlalchemy import SqlalchemyOrmPage
from pyramid import testing from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttjamaican.util import get_timezone_by_name
from wuttaweb.grids import base as mod from wuttaweb.grids import base as mod
from wuttaweb.grids.filters import ( from wuttaweb.grids.filters import (
GridFilter, GridFilter,
@ -1655,21 +1654,16 @@ class TestGrid(WebTestCase):
self.assertEqual(result, "2025-01-13") self.assertEqual(result, "2025-01-13")
def test_render_datetime(self): def test_render_datetime(self):
tzlocal = get_timezone_by_name("America/Los_Angeles") grid = self.make_grid(columns=["foo", "bar"])
with patch.object(self.app, "get_timezone", return_value=tzlocal):
grid = self.make_grid(columns=["foo", "bar"])
# null obj = MagicMock(dt=None)
obj = MagicMock(dt=None) result = grid.render_datetime(obj, "dt", None)
result = grid.render_datetime(obj, "dt", None) self.assertEqual(result, "")
self.assertEqual(result, "")
# normal (naive utc) dt = datetime.datetime(2024, 12, 12, 13, 44, tzinfo=datetime.timezone.utc)
dt = datetime.datetime(2024, 12, 12, 13, 44) obj = MagicMock(dt=dt)
obj = MagicMock(dt=dt) result = grid.render_datetime(obj, "dt", str(dt))
result = grid.render_datetime(obj, "dt", str(dt)) self.assertEqual(result, "2024-12-12 13:44+0000")
self.assertEqual(result, "2024-12-12 05:44-0800")
self.assertNotEqual(result, str(dt))
def test_render_vue_tag(self): def test_render_vue_tag(self):
grid = self.make_grid(columns=["foo", "bar"]) grid = self.make_grid(columns=["foo", "bar"])

View file

@ -1663,9 +1663,6 @@ class TestMasterView(WebTestCase):
def test_configure(self): def test_configure(self):
self.pyramid_config.include("wuttaweb.views.common") self.pyramid_config.include("wuttaweb.views.common")
self.pyramid_config.include("wuttaweb.views.auth") self.pyramid_config.include("wuttaweb.views.auth")
self.pyramid_config.add_route(
"appinfo.check_timezone", "/appinfo/check-timezone"
)
model = self.app.model model = self.app.model
# mock settings # mock settings
@ -1700,7 +1697,6 @@ class TestMasterView(WebTestCase):
def get_context(**kw): def get_context(**kw):
kw = original_context(**kw) kw = original_context(**kw)
kw["menu_handlers"] = [] kw["menu_handlers"] = []
kw["default_timezone"] = "UTC"
return kw return kw
with patch.object(view, "configure_get_context", new=get_context): with patch.object(view, "configure_get_context", new=get_context):

View file

@ -46,26 +46,6 @@ class TestAppInfoView(WebTestCase):
view = self.make_view() view = self.make_view()
context = view.configure_get_context() context = view.configure_get_context()
def test_configure_check_timezone(self):
view = self.make_view()
# normal
with patch.object(self.request, "GET", new={"tzname": "America/Chicago"}):
result = view.configure_check_timezone()
self.assertFalse(result["invalid"])
# invalid
with patch.object(self.request, "GET", new={"tzname": "bad_name"}):
result = view.configure_check_timezone()
self.assertEqual(
result["invalid"], "'No time zone found with key bad_name'"
)
# missing input
with patch.object(self.request, "GET", new={}):
result = view.configure_check_timezone()
self.assertEqual(result["invalid"], "Must provide 'tzname' parameter.")
class TestSettingView(WebTestCase): class TestSettingView(WebTestCase):