Files
tliu93 8b91146c29
frontend / frontend (push) Successful in 2m5s
pytest / test (push) Successful in 7m34s
FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py).
- summarize: fixed fee / tax credit accrue only for elapsed whole local days,
  integrated across contract versions — no future-day inflation, no version-switch collapse.
- MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline
  apportionment), anchored at the active contract's earliest version effective_from so the
  total sensors stay monotonic and never jump backward.
- effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC.
- frontend ContractForm sends local-midnight naive datetime (was UTC midnight).
- get_costs_summary default window uses server-local "today".
2026-06-25 11:13:30 +02:00

101 lines
3.6 KiB
Python

"""Server local-timezone helpers.
All time is stored as UTC (no change). This module provides a single,
monkeypatch-friendly entry point for converting UTC moments to the server's
local timezone — used for business-day boundary calculations (standing charges,
effective-from date interpretation, etc.).
Design goals
------------
- Zero third-party dependencies (no tzlocal, no pytz).
- Testable without depending on CI host timezone:
monkeypatch ``local_tz`` to a fixed ``ZoneInfo`` in unit tests.
- DST-correct: conversions use ``astimezone(local_tz())`` per instant, so each
moment is independently correct even during DST transitions.
- Consistent with the existing ``.astimezone()`` pattern already used in
``homeassistant_inbound.py`` and ``poo.py``.
Priority for resolving the local timezone
-----------------------------------------
1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``.
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``.
This matches the behaviour callers already relied on implicitly.
"""
from __future__ import annotations
import os
from datetime import date, datetime, timezone
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from datetime import tzinfo
def local_tz() -> "tzinfo":
"""Return the server's local timezone.
Resolution order:
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
2. System local timezone via ``datetime.now().astimezone().tzinfo``.
**Monkeypatch this function in tests** to get deterministic timezone
behaviour regardless of CI host configuration::
monkeypatch.setattr("app.services.timezone.local_tz",
lambda: ZoneInfo("Europe/Amsterdam"))
"""
tz_env = os.environ.get("TZ", "").strip()
if tz_env:
return ZoneInfo(tz_env)
# System fallback — identical to the .astimezone() pattern already used
# in homeassistant_inbound.py and poo.py.
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
def to_local(dt: datetime) -> datetime:
"""Convert *dt* to the server local timezone.
Handles three input cases:
- timezone-aware (any zone): converted via ``astimezone``.
- timezone-naive: assumed UTC (SQLite read-back convention), then converted.
Returns a timezone-aware datetime in ``local_tz()``.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(local_tz())
def local_now() -> datetime:
"""Return the current moment as a timezone-aware local datetime."""
return datetime.now(local_tz())
def local_date(dt: datetime) -> date:
"""Return the local calendar date for *dt*.
Equivalent to ``to_local(dt).date()``. A distinct helper to make
call sites readable.
"""
return to_local(dt).date()
def local_midnight_utc(d: date) -> datetime:
"""Return the UTC instant that corresponds to local midnight on *d*.
Example (Europe/Amsterdam, CEST=UTC+2):
local_midnight_utc(date(2026, 6, 25))
→ datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC)
This is used to convert a local calendar date back to a UTC boundary, e.g.
to compute the start/end of a local day for DB queries.
"""
tz = local_tz()
# Build a naive local datetime at midnight, then localize and convert to UTC.
local_midnight = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
return local_midnight.astimezone(timezone.utc)