notifications: correct timezone handling + configurable site timezone
Notification start/end times displayed and stored wrong by the tz offset (a 2:34 PM entry showed 6:34 PM). Two stacked bugs: to_dict emitted stored UTC as naive ISO (no offset) so the browser read it as local, and the form filled the datetime-local input from toISOString() (UTC). Fix and generalize to a configurable site timezone (multi-site): - New setting site_timezone (default America/New_York), public, editable in Settings > Site > Localization (common-zone dropdown). - Backend tags datetimes UTC (_utc_iso); parse normalizes to naive UTC (_parse_utc); daily-reset expiry uses the site zone (_next_site_time); calendar allDay events key off the site-local day (_site_date). - Shared frontend util datetime.js (Intl-based, DST-safe) converts between a UTC instant and a site-zone wall clock. Notification form, list, and calendar all render/enter in the site zone.
This commit is contained in:
@@ -1,9 +1,48 @@
|
||||
"""Notifications plugin models - adapted to existing database schema."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
_DEFAULT_TZ = 'America/New_York'
|
||||
|
||||
|
||||
def _site_zone():
|
||||
"""Site-configured IANA zone (settings key site_timezone) for calendar day
|
||||
placement. Imported lazily to avoid a circular import at model load."""
|
||||
from shopdb.api import Setting
|
||||
row = Setting.query.filter_by(key='site_timezone').first()
|
||||
name = row.value if row and row.value else _DEFAULT_TZ
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo(_DEFAULT_TZ)
|
||||
|
||||
|
||||
def _site_date(dt):
|
||||
"""The calendar day (YYYY-MM-DD) a stored UTC datetime falls on in the site
|
||||
zone. allDay events must key off the site-local day, not the UTC day, or a
|
||||
late-evening notification lands on the wrong date for western sites."""
|
||||
if not dt:
|
||||
return None
|
||||
return dt.replace(tzinfo=timezone.utc).astimezone(_site_zone()).date().isoformat()
|
||||
|
||||
|
||||
def _utc_iso(dt):
|
||||
"""Serialize a stored datetime as an explicit-UTC ISO string.
|
||||
|
||||
starttime/endtime are stored NAIVE but always hold UTC wall-clock (the
|
||||
create/update parse normalizes to UTC). Emitting a bare naive isoformat let
|
||||
the browser read it as LOCAL time, shifting displays by the tz offset (a
|
||||
14:34 EDT notification showed 18:34). Tag it UTC so new Date() parses the
|
||||
real instant and renders in the viewer's zone.
|
||||
"""
|
||||
if not dt:
|
||||
return None
|
||||
return dt.replace(tzinfo=timezone.utc).isoformat()
|
||||
|
||||
|
||||
class NotificationType(db.Model):
|
||||
"""
|
||||
Notification type classification.
|
||||
@@ -120,10 +159,10 @@ class Notification(db.Model):
|
||||
'notification': self.notification,
|
||||
'title': self.title,
|
||||
'message': self.notification,
|
||||
'starttime': self.starttime.isoformat() if self.starttime else None,
|
||||
'endtime': self.endtime.isoformat() if self.endtime else None,
|
||||
'startdate': self.starttime.isoformat() if self.starttime else None,
|
||||
'enddate': self.endtime.isoformat() if self.endtime else None,
|
||||
'starttime': _utc_iso(self.starttime),
|
||||
'endtime': _utc_iso(self.endtime),
|
||||
'startdate': _utc_iso(self.starttime),
|
||||
'enddate': _utc_iso(self.endtime),
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'link': self.link,
|
||||
'linkurl': self.link,
|
||||
@@ -175,8 +214,8 @@ class Notification(db.Model):
|
||||
return {
|
||||
'id': self.notificationid,
|
||||
'title': title,
|
||||
'start': self.starttime.isoformat() if self.starttime else None,
|
||||
'end': self.endtime.isoformat() if self.endtime else None,
|
||||
'start': _site_date(self.starttime),
|
||||
'end': _site_date(self.endtime),
|
||||
'allDay': True,
|
||||
'backgroundColor': color,
|
||||
'borderColor': color,
|
||||
|
||||
Reference in New Issue
Block a user