M8-R11: make business timezone deterministic
This commit is contained in:
@@ -14,6 +14,7 @@ AUTH_BOOTSTRAP_PASSWORD=change-me
|
|||||||
|
|
||||||
# Optional: runtime overrides.
|
# Optional: runtime overrides.
|
||||||
# Leave these commented out to use the application's built-in defaults.
|
# Leave these commented out to use the application's built-in defaults.
|
||||||
|
# TZ=Europe/Amsterdam
|
||||||
# APP_DEBUG=
|
# APP_DEBUG=
|
||||||
# AUTH_SESSION_COOKIE_NAME=
|
# AUTH_SESSION_COOKIE_NAME=
|
||||||
# AUTH_SESSION_TTL_HOURS=
|
# AUTH_SESSION_TTL_HOURS=
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ Priority for resolving the local timezone
|
|||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``.
|
1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``.
|
||||||
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
|
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
|
||||||
2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``.
|
2. The DST-aware ``Europe/Amsterdam`` business timezone. This keeps local-day
|
||||||
This matches the behaviour callers already relied on implicitly.
|
calculations deterministic when a deployment does not set ``TZ``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -40,7 +40,7 @@ def local_tz() -> "tzinfo":
|
|||||||
Resolution order:
|
Resolution order:
|
||||||
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
|
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
|
||||||
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
|
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
|
||||||
2. System local timezone via ``datetime.now().astimezone().tzinfo``.
|
2. The DST-aware ``Europe/Amsterdam`` business timezone.
|
||||||
|
|
||||||
**Monkeypatch this function in tests** to get deterministic timezone
|
**Monkeypatch this function in tests** to get deterministic timezone
|
||||||
behaviour regardless of CI host configuration::
|
behaviour regardless of CI host configuration::
|
||||||
@@ -51,9 +51,7 @@ def local_tz() -> "tzinfo":
|
|||||||
tz_env = os.environ.get("TZ", "").strip()
|
tz_env = os.environ.get("TZ", "").strip()
|
||||||
if tz_env:
|
if tz_env:
|
||||||
return ZoneInfo(tz_env)
|
return ZoneInfo(tz_env)
|
||||||
# System fallback — identical to the .astimezone() pattern already used
|
return ZoneInfo("Europe/Amsterdam")
|
||||||
# in homeassistant_inbound.py and poo.py.
|
|
||||||
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
def to_local(dt: datetime) -> datetime:
|
def to_local(dt: datetime) -> datetime:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
init: true
|
init: true
|
||||||
command: ["python", "-m", "scripts.run_migrations"]
|
command: ["python", "-m", "scripts.run_migrations"]
|
||||||
|
environment:
|
||||||
|
TZ: "${TZ:-Europe/Amsterdam}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./.env:/app/.env:ro
|
- ./.env:/app/.env:ro
|
||||||
@@ -17,6 +19,8 @@ services:
|
|||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
|
environment:
|
||||||
|
TZ: "${TZ:-Europe/Amsterdam}"
|
||||||
depends_on:
|
depends_on:
|
||||||
migration:
|
migration:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ def test_compose_uses_migration_job_before_app() -> None:
|
|||||||
assert dev["services"]["app"]["build"] == "."
|
assert dev["services"]["app"]["build"] == "."
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_defaults_business_timezone_and_dev_inherits_it() -> None:
|
||||||
|
"""Both production services receive an overridable Amsterdam timezone.
|
||||||
|
|
||||||
|
Compose merges service ``environment`` mappings, and the dev override does
|
||||||
|
not replace either mapping, so the base default applies to base+dev too.
|
||||||
|
"""
|
||||||
|
base = _read_yaml("docker-compose.yml")
|
||||||
|
dev = _read_yaml("docker-compose.dev.yml")
|
||||||
|
default_tz = "${TZ:-Europe/Amsterdam}"
|
||||||
|
|
||||||
|
for service_name in ("migration", "app"):
|
||||||
|
assert base["services"][service_name]["environment"]["TZ"] == default_tz
|
||||||
|
assert "TZ" not in dev["services"].get(service_name, {}).get("environment", {})
|
||||||
|
|
||||||
|
|
||||||
def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None:
|
def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None:
|
||||||
"""Base Compose maps the configured device with only pyserial's required access.
|
"""Base Compose maps the configured device with only pyserial's required access.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -139,7 +140,9 @@ def test_meter_cost_summary_empty_and_recompute_csrf_window_validation(meter_cos
|
|||||||
).status_code == 422
|
).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_meter_cost_summary_returns_five_fixed_components_and_totals(meter_cost_client) -> None:
|
def test_meter_cost_summary_returns_five_fixed_components_and_totals(
|
||||||
|
meter_cost_client, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
client, engine = meter_cost_client
|
client, engine = meter_cost_client
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
values = {
|
values = {
|
||||||
@@ -166,8 +169,10 @@ def test_meter_cost_summary_returns_five_fixed_components_and_totals(meter_cost_
|
|||||||
db.add_all((heating, water))
|
db.add_all((heating, water))
|
||||||
db.commit()
|
db.commit()
|
||||||
_login(client)
|
_login(client)
|
||||||
with patch("app.api.routes.api.meter_costs.local_now", return_value=datetime(2026, 6, 25, 2, tzinfo=UTC)):
|
monkeypatch.setattr("app.services.timezone.local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
response = client.get("/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z")
|
response = client.get(
|
||||||
|
"/api/energy/meter-costs/summary?scope=thermal&start=2026-06-23T00:00:00Z&end=2026-06-24T00:00:00Z"
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["heating"] == "1.000000000" and body["hot_water_heating"] == "0.8"
|
assert body["heating"] == "1.000000000" and body["hot_water_heating"] == "0.8"
|
||||||
|
|||||||
+33
-1
@@ -7,7 +7,7 @@ on any CI host timezone.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -56,6 +56,38 @@ def test_local_tz_env_var_overrides(monkeypatch):
|
|||||||
monkeypatch.delenv("TZ")
|
monkeypatch.delenv("TZ")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [None, "", " "])
|
||||||
|
def test_local_tz_defaults_to_dst_aware_amsterdam(monkeypatch, value: str | None):
|
||||||
|
"""Unset or blank TZ must use the deterministic Amsterdam business zone."""
|
||||||
|
if value is None:
|
||||||
|
monkeypatch.delenv("TZ", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("TZ", value)
|
||||||
|
|
||||||
|
tz = tz_mod.local_tz()
|
||||||
|
assert isinstance(tz, ZoneInfo)
|
||||||
|
assert tz.key == "Europe/Amsterdam"
|
||||||
|
winter = datetime(2026, 1, 15, 12, tzinfo=tz)
|
||||||
|
summer = datetime(2026, 7, 15, 12, tzinfo=tz)
|
||||||
|
assert winter.utcoffset().total_seconds() == 3600
|
||||||
|
assert summer.utcoffset().total_seconds() == 7200
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_tz_explicit_utc_override(monkeypatch):
|
||||||
|
"""A non-empty TZ remains an operator-controlled override."""
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
tz = tz_mod.local_tz()
|
||||||
|
assert isinstance(tz, ZoneInfo)
|
||||||
|
assert tz.key == "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_tz_invalid_explicit_override_fails_loudly(monkeypatch):
|
||||||
|
"""A non-empty invalid override must not silently become Amsterdam."""
|
||||||
|
monkeypatch.setenv("TZ", "Invalid/Timezone")
|
||||||
|
with pytest.raises(ZoneInfoNotFoundError):
|
||||||
|
tz_mod.local_tz()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# to_local() — conversion correctness
|
# to_local() — conversion correctness
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user