Publish the API's effective timezone and tolerate an older snapshot payload
TZ-002: with no zone stored the API reads its clock in the host's local time, but the panel fell back to Home Assistant's zone and the admin app to the browser's, so dates could disagree near midnight. The API now names the zone it actually uses (api/host_zone.py: the stored zone, else the host's own IANA zone from TZ, /etc/localtime or /etc/timezone) and publishes it as the panel snapshot's `timezone` and the admin settings' read-only `effective_timezone`. Both cards and the admin app format in that one zone; Home Assistant's or the browser's zone applies only when none is published. TZ-003: snapshot_from_api_payload reads `timezone` with a "" default, so an integration upgraded ahead of its API keeps refreshing against the pre-change payload. Task 7e173881-fb99-426f-a2f0-2678439ffc39 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""The API's effective application timezone (task 7e173881).
|
||||
|
||||
The admin-stored ``timezone`` setting is the single source of local-time
|
||||
truth; stored empty, the API reads its clock in the API host's local
|
||||
time. :func:`effective_timezone` names the zone the API actually uses —
|
||||
the stored zone, or else the host's own IANA zone — so the panel cards
|
||||
and the admin app format local time in exactly the API's zone instead of
|
||||
guessing with Home Assistant's or the browser's. ``""`` only when the
|
||||
host's zone genuinely cannot be named.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
|
||||
def _valid_zone(name: str) -> str:
|
||||
"""Return ``name`` if it is a loadable IANA zone key, else ``""``."""
|
||||
name = name.strip().removeprefix(":")
|
||||
if not name:
|
||||
return ""
|
||||
try:
|
||||
ZoneInfo(name)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
def host_timezone(
|
||||
environ: Mapping[str, str] = os.environ,
|
||||
timezone_file: Path = Path("/etc/timezone"),
|
||||
localtime: Path = Path("/etc/localtime"),
|
||||
) -> str:
|
||||
"""Name the API host's local zone as an IANA key, or ``""``.
|
||||
|
||||
In the order the C library honours them: the ``TZ`` environment
|
||||
variable, then the ``/etc/localtime`` zoneinfo link (with Debian's
|
||||
``/etc/timezone`` naming it when the link is a copy). A host with
|
||||
none of them configured runs UTC. A setting that names no IANA
|
||||
zone (a POSIX rule string, a copied file) is ``""``.
|
||||
"""
|
||||
configured = environ.get("TZ")
|
||||
if configured is not None:
|
||||
# An empty TZ is UTC to the C library.
|
||||
return _valid_zone(configured) if configured.strip() else "UTC"
|
||||
if localtime.is_symlink():
|
||||
target = str(localtime.resolve())
|
||||
marker = "/zoneinfo/"
|
||||
if marker in target:
|
||||
return _valid_zone(target.split(marker, 1)[1])
|
||||
if timezone_file.is_file():
|
||||
return _valid_zone(timezone_file.read_text(encoding="utf-8"))
|
||||
if not localtime.exists():
|
||||
return "UTC"
|
||||
return ""
|
||||
|
||||
|
||||
def effective_timezone(stored: str) -> str:
|
||||
"""The zone the API reads its clock in: ``stored``, else the host's."""
|
||||
return stored or host_timezone()
|
||||
+16
-5
@@ -170,7 +170,10 @@ are deliberately not unified here:
|
||||
notify target, the three notification times, the four enable
|
||||
toggles, and the ``timezone`` — the IANA household zone the
|
||||
household-local dates derive from, empty meaning host local; only an
|
||||
admin's ``PATCH`` ever sets it) — the defaults on a fresh database.
|
||||
admin's ``PATCH`` ever sets it) — the defaults on a fresh database —
|
||||
plus the read-only ``effective_timezone`` (:mod:`api.host_zone`): the
|
||||
zone the API actually reads its clock in, which the admin app formats
|
||||
local time in.
|
||||
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
||||
JSON object of settings field names to new values, passed STRAIGHT
|
||||
to :func:`nestquest_core.settings_store.update_settings` — the
|
||||
@@ -349,6 +352,7 @@ from pydantic import (
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from api import host_zone
|
||||
from api import transitions as api_transitions
|
||||
from api.auth import require_admin
|
||||
from api.database import DatabaseState
|
||||
@@ -920,9 +924,14 @@ class AdminSettingsResponse(BaseModel):
|
||||
afternoon_reminder_enabled: bool
|
||||
end_of_day_report_enabled: bool
|
||||
celebration_enabled: bool
|
||||
#: The household IANA time zone the API reads its clock in; ``""``
|
||||
#: means the API host's local time.
|
||||
#: The household IANA time zone the admin stored; ``""`` means the
|
||||
#: API host's local time.
|
||||
timezone: str
|
||||
#: The zone the API actually reads its clock in — ``timezone``, or
|
||||
#: the API host's own zone when that is ``""`` (``""`` only when the
|
||||
#: host's zone cannot be named). Read-only; the admin app formats
|
||||
#: local time in it.
|
||||
effective_timezone: str
|
||||
|
||||
|
||||
class AdminMissedSweepResponse(BaseModel):
|
||||
@@ -2179,8 +2188,9 @@ def _settings_response(
|
||||
"""Serialize one core NestQuestSettings into the documented payload.
|
||||
|
||||
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
||||
stays pinned to the eleven documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
stays pinned to the eleven documented fields (plus the read-only
|
||||
``effective_timezone``) even if the core dataclass later grows
|
||||
another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
horizon_days=settings.horizon_days,
|
||||
@@ -2194,6 +2204,7 @@ def _settings_response(
|
||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||
celebration_enabled=settings.celebration_enabled,
|
||||
timezone=settings.timezone,
|
||||
effective_timezone=host_zone.effective_timezone(settings.timezone),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+9
-6
@@ -47,8 +47,10 @@ active children ``ORDER BY sort_order, id``); ``state`` is the
|
||||
documented panel spelling ``open`` | ``completed`` (§1 of
|
||||
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
|
||||
in the first scheduled child's custody cycle (0 when no child has one);
|
||||
``timezone`` is the application timezone the admin stored (``""`` when
|
||||
unset), which the panel cards format their visible date and clock in.
|
||||
``timezone`` is the effective application timezone — the zone the admin
|
||||
stored, or the API host's own zone when none is (``""`` only when the
|
||||
host's zone cannot be named; :mod:`api.host_zone`) — which the panel
|
||||
cards format their visible date and clock in.
|
||||
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
|
||||
``response_model``, so the OpenAPI document carries the shape too.
|
||||
"""
|
||||
@@ -69,6 +71,7 @@ from api.nestquest_core import (
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import host_zone
|
||||
from api import transitions as api_transitions
|
||||
from api.sse import transition_event_stream
|
||||
|
||||
@@ -129,9 +132,9 @@ class PanelSnapshotResponse(BaseModel):
|
||||
|
||||
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
|
||||
1-based day in the first scheduled child's custody cycle (0 when no
|
||||
child has a schedule); ``timezone`` is the stored application
|
||||
timezone (``""`` when unset); ``children`` is in the household's
|
||||
sort order.
|
||||
child has a schedule); ``timezone`` is the effective application
|
||||
timezone (:func:`api.host_zone.effective_timezone`); ``children`` is
|
||||
in the household's sort order.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
@@ -223,7 +226,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
||||
return PanelSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
timezone=snapshot.timezone,
|
||||
timezone=host_zone.effective_timezone(snapshot.timezone),
|
||||
children=[
|
||||
PanelChildPayload(
|
||||
child_id=child.child_id,
|
||||
|
||||
Reference in New Issue
Block a user