from datetime import UTC, date, datetime from typing import Annotated from zoneinfo import ZoneInfo from pydantic import AfterValidator from app.core.config import get_settings from app.core.errors import DomainValidationError def utcnow() -> datetime: return datetime.now(UTC) def resolve_tz(tz: str | None) -> ZoneInfo: name = tz or get_settings().timezone try: return ZoneInfo(name) except Exception as exc: raise DomainValidationError(f"Fuseau horaire inconnu : {name}") from exc def local_day(dt_utc: datetime, tz: ZoneInfo) -> date: return dt_utc.astimezone(tz).date() def as_utc(value: datetime) -> datetime: """Normalise a *stored* instant to UTC-aware. Every instant is persisted in UTC (CONVENTIONS C2.3), but the driver decides what comes back: PostgreSQL `TIMESTAMPTZ` yields an aware datetime while SQLite yields a naive one. Stamping UTC on naive values keeps responses compliant with the `Z` suffix required by architecture §8.4 on both engines. """ return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) def require_utc(value: datetime) -> datetime: """Validate an *inbound* instant: naive is refused, aware is converted to UTC. Architecture §8.4: "entrée avec offset acceptée et convertie en UTC ; entrée naïve refusée (422)". Silently reading a naive datetime as UTC would record a local wall-clock time off by the client's offset. """ if value.tzinfo is None or value.utcoffset() is None: raise ValueError( "Horodatage sans fuseau horaire : précisez le décalage " "(par exemple « 2026-08-13T06:31:00Z »)." ) return value.astimezone(UTC) # Use in request schemas (XxxCreate/XxxUpdate) for any client-supplied instant. UtcDatetime = Annotated[datetime, AfterValidator(require_utc)] # Use in response schemas (XxxRead) for any instant read back from the database. StoredUtcDatetime = Annotated[datetime, AfterValidator(as_utc)]