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/)
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

View file

@ -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",
]

View file

@ -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

View file

@ -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)

View file

@ -48,16 +48,6 @@
</b-checkbox>
</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">
<input type="hidden"
name="${app.appname}.web.menus.handler.spec"
@ -280,74 +270,6 @@
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.editWebLibraryShowDialog = false

View file

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

View file

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

View file

@ -11,85 +11,87 @@ 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)
)
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)
result = typ.serialize(node, None)
self.assertIs(result, colander.null)
result = typ.serialize(node, "")
self.assertIs(result, colander.null)
# naive, UTC
# TODO: must override local timezone for a complete test
result = typ.serialize(node, datetime.datetime(2024, 12, 11, 22, 33))
self.assertEqual(result, "2024-12-11T14:33:00-08:00")
self.assertTrue(result.startswith("2024-12-"))
# aware, UTC
result = typ.serialize(
node,
datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc),
node, datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc)
)
self.assertEqual(result, "2024-12-11T14:33:00-08:00")
self.assertEqual(result, "2024-12-11T22:33:00+00:00")
# aware, local
result = typ.serialize(
node,
datetime.datetime(2024, 12, 11, 14, 33, tzinfo=tzlocal),
datetime.datetime(
2024,
12,
11,
22,
33,
tzinfo=datetime.timezone(-datetime.timedelta(hours=5)),
),
)
self.assertEqual(result, "2024-12-11T14:33:00-08:00")
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, widget=widgets.WuttaDateTimeWidget(self.request)
)
node = colander.SchemaNode(typ)
result = typ.serialize(
node,
datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc),
node, datetime.datetime(2024, 12, 11, 22, 33, tzinfo=datetime.timezone.utc)
)
self.assertEqual(result, "2024-12-11 02:33 PM")
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)
)
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, "")
self.assertIs(result, colander.null)
# TODO: must override local timezone for a complete test
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)
)
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)
# invalid
self.assertRaises(colander.Invalid, typ.deserialize, node, "bogus")

View file

@ -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)
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())
# editable widget has normal picker html
result = widget.serialize(field, str(dt))
self.assertIn("<wutta-datepicker", result)
def test_serialize_readonly(self):
tzlocal = get_timezone_by_name("America/New_York")
with patch.object(self.app, "get_timezone", return_value=tzlocal):
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")
# readonly is rendered per app convention
result = widget.serialize(field, str(dt), readonly=True)
self.assertEqual(result, "2024-12-12 13:49+0000")
class TestWuttaMoneyInputWidget(WebTestCase):

View file

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

View file

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

View file

@ -46,26 +46,6 @@ class TestAppInfoView(WebTestCase):
view = self.make_view()
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):