Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
58 lines
2.0 KiB
Python
58 lines
2.0 KiB
Python
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)]
|