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".
This commit is contained in:
@@ -75,6 +75,7 @@ from app.schemas.energy import (
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.contracts import active_contract_version_at
|
||||
from app.services.energy_cost import recompute_range, summarize
|
||||
from app.services.timezone import local_midnight_utc, local_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -358,12 +359,15 @@ def get_costs_summary(
|
||||
Both ``fixed_costs`` and ``credits`` are derived from the **currently active
|
||||
contract version at ``end``**. When no active contract exists they are 0.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if start is None:
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if end is None:
|
||||
end = start + timedelta(days=1)
|
||||
if start is None or end is None:
|
||||
# Default to the server's local today: [local_midnight, next_local_midnight).
|
||||
# This ensures "today" aligns with the local calendar day (NL time) rather
|
||||
# than UTC midnight.
|
||||
local_today = local_now().date()
|
||||
if start is None:
|
||||
start = local_midnight_utc(local_today)
|
||||
if end is None:
|
||||
end = local_midnight_utc(local_today + timedelta(days=1))
|
||||
|
||||
result = summarize(db, _as_utc(start), _as_utc(end))
|
||||
return SummaryResponse(**result)
|
||||
|
||||
@@ -44,6 +44,7 @@ from app.schemas.energy_contract import (
|
||||
VersionCreate,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services import timezone as _tz_mod
|
||||
from app.services.contracts import (
|
||||
ContractVersionError,
|
||||
activate_contract,
|
||||
@@ -113,6 +114,30 @@ def _raise_422_for_profile_error(exc: Exception) -> Any:
|
||||
)
|
||||
|
||||
|
||||
def _localize_effective_from(dt: datetime | None) -> datetime:
|
||||
"""Resolve *dt* to an aware UTC datetime for storage.
|
||||
|
||||
Rules (Principle A):
|
||||
- If *dt* is None → use ``datetime.now(UTC)`` (unchanged from before).
|
||||
- If *dt* is timezone-aware → convert to UTC as-is.
|
||||
- If *dt* is timezone-naive → interpret as server local wall-clock time,
|
||||
localize with ``local_tz()``, then convert to UTC.
|
||||
|
||||
This means a front-end that sends ``"2026-06-25T00:00:00"`` (no Z) has it
|
||||
interpreted as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
|
||||
not as UTC midnight.
|
||||
"""
|
||||
if dt is None:
|
||||
return datetime.now(UTC)
|
||||
if dt.tzinfo is not None:
|
||||
# Already aware: convert to UTC.
|
||||
return dt.astimezone(UTC)
|
||||
# Naive: assume server local wall-clock.
|
||||
tz = _tz_mod.local_tz()
|
||||
local_dt = dt.replace(tzinfo=tz)
|
||||
return local_dt.astimezone(UTC)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -175,7 +200,7 @@ def create_energy_contract(
|
||||
The new contract is created with ``active=False``; use
|
||||
PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
|
||||
"""
|
||||
effective_from = body.effective_from or datetime.now(UTC)
|
||||
effective_from = _localize_effective_from(body.effective_from)
|
||||
|
||||
try:
|
||||
contract = create_contract(
|
||||
@@ -284,12 +309,13 @@ def add_contract_version(
|
||||
Historical versions are never modified; this endpoint is append-only.
|
||||
"""
|
||||
contract = _get_contract_or_404(db, contract_id)
|
||||
effective_from = _localize_effective_from(body.effective_from)
|
||||
|
||||
try:
|
||||
add_version(
|
||||
db,
|
||||
contract,
|
||||
effective_from=body.effective_from,
|
||||
effective_from=effective_from,
|
||||
values=body.values,
|
||||
)
|
||||
except ContractVersionError as exc:
|
||||
|
||||
+42
-47
@@ -549,43 +549,43 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
||||
|
||||
Value = SUM(import_cost, non-degraded) + standing_charges_cumulative
|
||||
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``,
|
||||
where ``anchor_utc`` is the effective_from of the earliest version of the active
|
||||
contract (i.e. the contract billing start). This makes the cumulative total
|
||||
monotonically increasing and cross-version consistent:
|
||||
|
||||
where standing_charges_cumulative = elapsed_days × (network_fee + management_fee) / 30.
|
||||
Elapsed days are counted from the active contract version's effective_from date
|
||||
(inclusive of today). Returns None when no non-degraded periods exist.
|
||||
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
|
||||
|
||||
Returns None when no non-degraded period exists in [anchor, now].
|
||||
No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from decimal import Decimal
|
||||
from datetime import UTC, datetime as _dt
|
||||
|
||||
from app.services.contracts import active_contract_versions
|
||||
from app.services.contracts import _as_utc as _cu
|
||||
from app.services.energy_cost import summarize as _summarize
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from app.services.contracts import active_contract_version_at, _as_utc
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
total = sess.query(_func.sum(_ECP.import_cost)).filter(
|
||||
# Quick check: any non-degraded period at all? (avoids full summarize overhead
|
||||
# when there are zero periods, which should return None)
|
||||
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if total is None:
|
||||
if has_data is None:
|
||||
return None
|
||||
|
||||
# Add prorated standing charges from the active contract version.
|
||||
standing_cumulative = Decimal("0")
|
||||
versions = active_contract_versions(sess)
|
||||
if not versions:
|
||||
# No active contract — return pure SUM(import_cost) without standing charges.
|
||||
return float(has_data)
|
||||
|
||||
# anchor = earliest version's effective_from (contract billing start).
|
||||
anchor_utc = _cu(versions[0].effective_from)
|
||||
now_utc = _dt.now(UTC)
|
||||
version = active_contract_version_at(sess, now_utc)
|
||||
if version is not None:
|
||||
vals = version.values or {}
|
||||
standing = vals.get("standing", {})
|
||||
network_fee = Decimal(str(standing.get("network_fee") or 0))
|
||||
management_fee = Decimal(str(standing.get("management_fee") or 0))
|
||||
daily_standing = (network_fee + management_fee) / Decimal("30")
|
||||
|
||||
effective_from = _as_utc(version.effective_from)
|
||||
days = (now_utc.date() - effective_from.date()).days + 1
|
||||
days = max(days, 0)
|
||||
standing_cumulative = daily_standing * days
|
||||
|
||||
return float(Decimal(str(total)) + standing_cumulative)
|
||||
result = _summarize(sess, anchor_utc, now_utc)
|
||||
return result["metered_import"] + result["fixed_costs"]
|
||||
|
||||
return _getter
|
||||
|
||||
@@ -594,42 +594,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
||||
|
||||
Value = SUM(export_revenue, non-degraded) + heffingskorting_cumulative
|
||||
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``.
|
||||
|
||||
where heffingskorting_cumulative = elapsed_days × heffingskorting / 365.
|
||||
Elapsed days are counted from the active contract version's effective_from date
|
||||
(inclusive of today). Returns None when no non-degraded periods exist.
|
||||
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
|
||||
|
||||
Returns None when no non-degraded period exists in [anchor, now].
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from decimal import Decimal
|
||||
from datetime import UTC, datetime as _dt
|
||||
|
||||
from app.services.contracts import active_contract_versions
|
||||
from app.services.contracts import _as_utc as _cu
|
||||
from app.services.energy_cost import summarize as _summarize
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from app.services.contracts import active_contract_version_at, _as_utc
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
total = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||
# Quick check: any non-degraded period at all?
|
||||
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if total is None:
|
||||
if has_data is None:
|
||||
return None
|
||||
|
||||
# Add prorated heffingskorting credit from the active contract version.
|
||||
credit_cumulative = Decimal("0")
|
||||
versions = active_contract_versions(sess)
|
||||
if not versions:
|
||||
# No active contract — return pure SUM(export_revenue) without credits.
|
||||
return float(has_data)
|
||||
|
||||
anchor_utc = _cu(versions[0].effective_from)
|
||||
now_utc = _dt.now(UTC)
|
||||
version = active_contract_version_at(sess, now_utc)
|
||||
if version is not None:
|
||||
vals = version.values or {}
|
||||
credits = vals.get("credits", {})
|
||||
heffingskorting = Decimal(str(credits.get("heffingskorting") or 0))
|
||||
daily_credit = heffingskorting / Decimal("365")
|
||||
|
||||
effective_from = _as_utc(version.effective_from)
|
||||
days = (now_utc.date() - effective_from.date()).days + 1
|
||||
days = max(days, 0)
|
||||
credit_cumulative = daily_credit * days
|
||||
|
||||
return float(Decimal(str(total)) + credit_cumulative)
|
||||
result = _summarize(sess, anchor_utc, now_utc)
|
||||
return result["metered_export"] + result["credits"]
|
||||
|
||||
return _getter
|
||||
|
||||
|
||||
@@ -286,6 +286,32 @@ def deactivate_contract(session: Session, contract: EnergyContract) -> None:
|
||||
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
|
||||
|
||||
|
||||
def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
|
||||
"""Return all versions of the currently active contract, ordered by effective_from ascending.
|
||||
|
||||
Returns an empty list when there is no active contract. The list spans the
|
||||
full history of the active contract (all closed + the current open version)
|
||||
and is used to iterate over pricing-rate segments for cross-version fixed-
|
||||
cost / credit accumulation (Principle C).
|
||||
"""
|
||||
active = session.execute(
|
||||
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if active is None:
|
||||
return []
|
||||
|
||||
return list(
|
||||
session.execute(
|
||||
select(EnergyContractVersion)
|
||||
.where(EnergyContractVersion.contract_id == active.id)
|
||||
.order_by(EnergyContractVersion.effective_from)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def active_contract_version_at(
|
||||
session: Session, ts: datetime
|
||||
) -> EnergyContractVersion | None:
|
||||
|
||||
+102
-26
@@ -60,7 +60,8 @@ from app.integrations.pricing.strategies import (
|
||||
get_strategy,
|
||||
)
|
||||
from app.models.energy import DsmrReading, EnergyCostPeriod
|
||||
from app.services.contracts import active_contract_version_at
|
||||
from app.services.contracts import active_contract_version_at, active_contract_versions
|
||||
from app.services.timezone import local_date, local_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -496,13 +497,23 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
||||
Computes the total payable as:
|
||||
|
||||
total_payable = Σ(net_cost) -- metered electricity
|
||||
+ fixed_costs -- (network_fee + management_fee) EUR/month ÷ 30 × days
|
||||
- credits -- heffingskorting EUR/year ÷ 365 × days
|
||||
+ fixed_costs -- per-day standing charges, cross-version
|
||||
- credits -- per-day heffingskorting, cross-version
|
||||
|
||||
The fixed costs and credits are derived from the *currently active contract
|
||||
version* at *end* (i.e. the version in effect at the end of the requested
|
||||
interval). When no active contract version exists, fixed costs and credits
|
||||
are both 0; only the metered sum is returned.
|
||||
**Fixed-cost / credit counting — Principle C**:
|
||||
Only *already-elapsed* whole local calendar days are counted. For each
|
||||
local calendar date D in the window ``[start_local_date, min(end_local_date,
|
||||
tomorrow_local))``, the contract version whose rate covers D (the one whose
|
||||
effective_from local-date ≤ D < next version's effective_from local-date) is
|
||||
used. Days that have not yet started in local time (D > today_local) are
|
||||
never counted. This is cross-version: if the active contract has V1 from
|
||||
June 1 and V2 from June 25, querying June 1–30 uses V1 for days 1-24 and V2
|
||||
for day 25. Switching versions never resets the counter.
|
||||
|
||||
**Timezone note**: the ``days`` field in the returned dict still represents
|
||||
the window length in calendar days (total_seconds / 86400), for backward
|
||||
compatibility with existing API consumers. The fixed/credit calculation
|
||||
independently counts whole local days as described above.
|
||||
|
||||
All arithmetic uses Decimal; the returned dict contains Python floats for
|
||||
JSON-serialisation convenience.
|
||||
@@ -512,9 +523,9 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
||||
session:
|
||||
Active read-only SQLAlchemy session.
|
||||
start:
|
||||
Inclusive start of the summary interval.
|
||||
Inclusive start of the summary interval (UTC or naive-UTC).
|
||||
end:
|
||||
Exclusive end of the summary interval.
|
||||
Exclusive end of the summary interval (UTC or naive-UTC).
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -524,13 +535,15 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
||||
metered_import float Σ import_cost from non-degraded periods
|
||||
metered_export float Σ export_revenue from non-degraded periods
|
||||
metered_net float Σ net_cost from non-degraded periods
|
||||
fixed_costs float standing charges apportioned over the interval
|
||||
credits float energy-tax credit apportioned over the interval
|
||||
fixed_costs float standing charges for elapsed whole local days
|
||||
credits float energy-tax credit for elapsed whole local days
|
||||
total_payable float metered_net + fixed_costs − credits
|
||||
period_count int number of non-degraded periods in range
|
||||
degraded_count int number of degraded periods in range
|
||||
days float interval length in days (total_seconds / 86400)
|
||||
"""
|
||||
from datetime import timedelta as _td, date as _date
|
||||
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
|
||||
@@ -550,32 +563,95 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
||||
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
|
||||
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
|
||||
|
||||
# --- Interval length in days ---
|
||||
# --- Interval length in days (window, not elapsed — kept for API compat) ---
|
||||
total_seconds = (end_utc - start_utc).total_seconds()
|
||||
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
||||
|
||||
# --- Active contract version at *end* for standing charges ---
|
||||
version = active_contract_version_at(session, end_utc)
|
||||
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
|
||||
today_local: _date = local_now().date()
|
||||
|
||||
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
|
||||
#
|
||||
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc)
|
||||
# AND D ≤ today_local (only elapsed / today days count as "whole days").
|
||||
#
|
||||
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
|
||||
# AND the local midnight is within the billing window."
|
||||
#
|
||||
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any
|
||||
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that
|
||||
# contains CEST midnight (= 22:00 UTC) contributes 1 day.
|
||||
#
|
||||
# Implementation: compute the first and last local date whose midnight is in range.
|
||||
from app.services.timezone import local_midnight_utc as _lmu
|
||||
|
||||
local_start_date: _date = local_date(start_utc)
|
||||
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day.
|
||||
if _lmu(local_start_date) >= start_utc:
|
||||
first_counted: _date = local_start_date
|
||||
else:
|
||||
first_counted = local_start_date + _td(days=1)
|
||||
|
||||
local_end_date: _date = local_date(end_utc)
|
||||
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
|
||||
if _lmu(local_end_date) < end_utc:
|
||||
last_counted: _date = local_end_date
|
||||
else:
|
||||
last_counted = local_end_date - _td(days=1)
|
||||
|
||||
# Cap: only elapsed or today's days.
|
||||
last_counted = min(last_counted, today_local)
|
||||
|
||||
# If the range is empty (first_counted > last_counted), no days are counted.
|
||||
|
||||
# Fetch all versions of the active contract once (single DB call).
|
||||
versions = active_contract_versions(session)
|
||||
|
||||
fixed_dec = Decimal("0")
|
||||
credits_dec = Decimal("0")
|
||||
currency = "EUR"
|
||||
|
||||
if version is not None:
|
||||
currency = version.contract.currency
|
||||
vals: dict = version.values or {}
|
||||
standing: dict = vals.get("standing", {})
|
||||
creds: dict = vals.get("credits", {})
|
||||
if versions:
|
||||
# Currency comes from the contract regardless of day count.
|
||||
currency = versions[0].contract.currency
|
||||
|
||||
network_fee = _to_decimal(standing.get("network_fee", 0))
|
||||
management_fee = _to_decimal(standing.get("management_fee", 0))
|
||||
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
|
||||
if versions and first_counted <= last_counted:
|
||||
# For each version segment, find its overlap with [first_counted, last_counted]
|
||||
# and accumulate whole days.
|
||||
version_segments: list[tuple[_date, _date | None, dict]] = []
|
||||
for v in versions:
|
||||
v_start_local = local_date(_as_utc(v.effective_from))
|
||||
v_end_local = local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None
|
||||
version_segments.append((v_start_local, v_end_local, v.values or {}))
|
||||
|
||||
# Standing charges: EUR/month → EUR/day (÷ 30) × days.
|
||||
fixed_dec = (network_fee + management_fee) / Decimal("30") * days
|
||||
for v_start, v_end_excl, v_values in version_segments:
|
||||
# The version covers [v_start, v_end_excl) in local dates,
|
||||
# where v_end_excl=None means open-ended (no upper bound).
|
||||
# Intersection with [first_counted, last_counted]:
|
||||
seg_start = max(first_counted, v_start)
|
||||
if v_end_excl is not None:
|
||||
seg_end_excl = min(last_counted + _td(days=1), v_end_excl)
|
||||
else:
|
||||
seg_end_excl = last_counted + _td(days=1)
|
||||
|
||||
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × days.
|
||||
credits_dec = heffingskorting / Decimal("365") * days
|
||||
if seg_start >= seg_end_excl:
|
||||
continue # no overlap
|
||||
|
||||
n_days = (seg_end_excl - seg_start).days
|
||||
if n_days <= 0:
|
||||
continue
|
||||
|
||||
standing: dict = v_values.get("standing", {})
|
||||
creds: dict = v_values.get("credits", {})
|
||||
|
||||
network_fee = _to_decimal(standing.get("network_fee", 0))
|
||||
management_fee = _to_decimal(standing.get("management_fee", 0))
|
||||
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
|
||||
|
||||
# Standing charges: EUR/month → EUR/day (÷ 30) × n_days.
|
||||
fixed_dec += (network_fee + management_fee) / Decimal("30") * Decimal(str(n_days))
|
||||
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × n_days.
|
||||
credits_dec += heffingskorting / Decimal("365") * Decimal(str(n_days))
|
||||
|
||||
total_payable = sum_net + fixed_dec - credits_dec
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user