From adb85420182562537a531b626210234e70ade684 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sat, 8 Aug 2026 14:08:33 -0400 Subject: [PATCH] backups: show timestamps in the site timezone Backup timestamps read wrong because of two faults stacked, which is why it looked like a single offset. The API serialised naive ISO ("2026-08-07T12:00:00"), with nothing saying the value was UTC. JavaScript's new Date() parses that as BROWSER-LOCAL, so every timestamp shifted by the viewer's offset before any timezone formatting ran. Every datetime this plugin stores is naive UTC, so the wire format now carries a trailing Z. The history view then formatted with toLocaleString(), i.e. the viewer's zone, ignoring the site_timezone setting entirely. It now loads that setting and formats through the shared formatInZone helper, matching NotificationsList. The panel list label is built server-side with strftime, so a client cannot correct it afterwards. It now converts to the site zone using the same Setting lookup the notifications plugin uses - without that it showed UTC, four hours out at West Jefferson. Tests cover the wire format and that 16:30Z renders as 12:30 in America/New_York. --- plugins/backups/api/routes.py | 37 +++++++++++++++-- .../backups/frontend/views/BackupHistory.vue | 24 +++++++++-- plugins/backups/models/backup.py | 18 +++++++- tests/test_plugins/test_backups.py | 41 +++++++++++++++++++ 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/plugins/backups/api/routes.py b/plugins/backups/api/routes.py index dba92ad..6eb54cd 100644 --- a/plugins/backups/api/routes.py +++ b/plugins/backups/api/routes.py @@ -12,6 +12,9 @@ because requiring the app server to mount the SFLD share would turn a permissions slip into an unexplained empty download. """ +from datetime import timezone +from zoneinfo import ZoneInfo + from flask import Blueprint, current_app, request, Response from flask_jwt_extended import jwt_required @@ -22,15 +25,42 @@ from shopdb.api import ( ) from ..models import BackupRevision +from ..models.backup import _utciso from ..services.registry import REGISTRY, getkind backups_bp = Blueprint('backups', __name__) +_DEFAULTTZ = 'America/New_York' + + +def _sitezone(): + """Site-configured IANA zone (settings key site_timezone). + + Same lookup the notifications plugin uses. A stored timestamp is UTC, so + anything rendered server-side has to be converted or it shows the wrong + wall clock for the site - four hours out at West Jefferson. + """ + from shopdb.api import Setting + row = Setting.query.filter_by(key='site_timezone').first() + name = row.value if row and row.value else _DEFAULTTZ + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo(_DEFAULTTZ) + + def _label(revision): - """Panel list title: kind-agnostic, readable at a glance.""" + """Panel list title: kind-agnostic, readable at a glance. + + Rendered in the SITE zone, not UTC: this string is baked server-side and + the client cannot correct it afterwards. + """ when = revision.collectedat or revision.createdat - stamp = when.strftime('%Y-%m-%d %H:%M') if when else 'unknown time' + if not when: + return 'unknown time' + local = when.replace(tzinfo=timezone.utc).astimezone(_sitezone()) + stamp = local.strftime('%Y-%m-%d %H:%M') if revision.sourcefilename: return '{} - {}'.format(stamp, revision.sourcefilename) return stamp @@ -129,8 +159,7 @@ def asset_info(assetid): revision.payload, assetid, partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES')) data['backuprevisionid'] = revision.backuprevisionid - data['collectedat'] = (revision.collectedat.isoformat() - if revision.collectedat else None) + data['collectedat'] = _utciso(revision.collectedat) return success_response(data) diff --git a/plugins/backups/frontend/views/BackupHistory.vue b/plugins/backups/frontend/views/BackupHistory.vue index b2286e9..625e689 100644 --- a/plugins/backups/frontend/views/BackupHistory.vue +++ b/plugins/backups/frontend/views/BackupHistory.vue @@ -95,7 +95,8 @@