feat(presence): ajoute le service métier de présence et ses tests

Étape 3 validée du service métier de présence : implémentation de
app/business/presence_service.py et couverture par
tests/test_presence_service.py.

Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
This commit is contained in:
2026-08-13 18:54:33 +02:00
parent e9f73c7562
commit a27f282b69
2 changed files with 390 additions and 0 deletions

View File

@@ -0,0 +1,227 @@
"""Enregistrement métier des événements de présence Home Assistant."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime, time
from typing import Any, Callable, Literal, Mapping
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
EventType = Literal["arrival", "departure"]
SlotState = Literal["open", "closed"]
class PresenceServiceError(ValueError):
"""Erreur métier prévisible lors de l'enregistrement d'une présence."""
class InvalidPresenceEventError(PresenceServiceError):
"""Les données de l'événement ne respectent pas le contrat métier."""
class IdempotencyConflictError(PresenceServiceError):
"""La clé est déjà utilisée par un événement différent."""
class ArrivalAlreadyOpenError(PresenceServiceError):
"""Une arrivée est déjà ouverte, quelle que soit sa journée."""
class DepartureWithoutArrivalError(PresenceServiceError):
"""Aucune arrivée ouverte ne peut être fermée."""
@dataclass(frozen=True)
class PresenceResult:
"""Résultat sérialisable par la future route API."""
event_id: int
entry_id: int
time_slot_id: int | None
replayed: bool
slot_state: SlotState
@property
def event_created(self) -> bool:
return not self.replayed
def _parse_occurred_at(value: str, timezone: ZoneInfo) -> tuple[datetime, datetime, date, time]:
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError) as exc:
raise InvalidPresenceEventError("occurred_at doit être un ISO 8601 valide") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise InvalidPresenceEventError("occurred_at doit comporter un offset explicite")
local = parsed.astimezone(timezone)
wall_time = local.replace(tzinfo=None)
return wall_time, local, local.date(), local.time()
def _received_at(value: datetime | None, clock: Callable[[], datetime] | None) -> datetime:
received = value if value is not None else (clock() if clock else datetime.now(UTC))
if received.tzinfo is None or received.utcoffset() is None:
return received
return received.astimezone(UTC).replace(tzinfo=None)
def _validate_config(config: Mapping[str, Any]) -> tuple[ZoneInfo, str, str, str]:
try:
timezone_name = config["timezone"]
defaults = (
config["default_day_type"],
config["default_journey_profile_id"],
config["default_motor_vehicle_id"],
)
timezone = ZoneInfo(timezone_name)
except (KeyError, TypeError, ZoneInfoNotFoundError, ValueError) as exc:
raise InvalidPresenceEventError("Configuration Home Assistant invalide") from exc
if not isinstance(timezone_name, str) or not all(isinstance(item, str) for item in defaults):
raise InvalidPresenceEventError("Configuration Home Assistant invalide")
return timezone, defaults[0], defaults[1], defaults[2]
def _result(event: WorkplacePresenceEvent, replayed: bool) -> PresenceResult:
return PresenceResult(
event_id=event.id,
entry_id=event.entry_id,
time_slot_id=event.time_slot_id,
replayed=replayed,
slot_state="closed" if event.time_slot_id is not None else "open",
)
def record_presence_event(
session: Session,
config: Mapping[str, Any],
event_type: str,
occurred_at: str,
idempotency_key: str,
*,
received_at: datetime | None = None,
clock: Callable[[], datetime] | None = None,
) -> PresenceResult:
"""Enregistre une arrivée ou un départ sans valider la transaction SQLAlchemy."""
if not hasattr(session, "in_transaction"):
session = session()
if event_type not in ("arrival", "departure"):
raise InvalidPresenceEventError("event_type doit valoir arrival ou departure")
if not isinstance(idempotency_key, str) or not idempotency_key or len(idempotency_key) > 255:
raise InvalidPresenceEventError(
"idempotency_key doit être non vide et limitée à 255 caractères"
)
timezone, day_type, journey_id, vehicle_id = _validate_config(config)
occurred_wall, occurred_local, local_date, wall_time = _parse_occurred_at(occurred_at, timezone)
received_wall = _received_at(received_at, clock)
# Le SELECT démarre explicitement la transaction racine. Aucun contexte ne
# valide cette transaction : la route appelante garde la décision finale.
session.execute(select(1))
try:
if session:
existing = session.scalar(
select(WorkplacePresenceEvent).where(
WorkplacePresenceEvent.idempotency_key == idempotency_key
)
)
if existing is not None:
if existing.event_type != event_type or existing.occurred_at != occurred_wall:
raise IdempotencyConflictError("La clé d'idempotence est déjà utilisée")
return _result(existing, replayed=True)
if event_type == "arrival":
# Une seule arrivée peut être ouverte dans toute l'application :
# le prochain départ doit toujours avoir un rattachement unique.
open_arrival = session.scalar(
select(WorkplacePresenceEvent).where(
WorkplacePresenceEvent.event_type == "arrival",
WorkplacePresenceEvent.time_slot_id.is_(None),
)
)
if open_arrival is not None:
raise ArrivalAlreadyOpenError("Une arrivée est déjà ouverte")
entry = session.scalar(select(WorkEntry).where(WorkEntry.date == local_date))
if entry is None:
entry = WorkEntry(
date=local_date,
day_type=day_type,
journey_profile_id=journey_id,
motor_vehicle_id=vehicle_id,
)
session.add(entry)
session.flush()
event = WorkplacePresenceEvent(
idempotency_key=idempotency_key,
event_type="arrival",
received_at=received_wall,
occurred_at=occurred_wall,
local_date=local_date,
entry=entry,
)
session.add(event)
session.flush()
return _result(event, replayed=False)
open_arrivals = session.scalars(
select(WorkplacePresenceEvent)
.where(
WorkplacePresenceEvent.event_type == "arrival",
WorkplacePresenceEvent.time_slot_id.is_(None),
)
.order_by(WorkplacePresenceEvent.occurred_at, WorkplacePresenceEvent.id)
).all()
if not open_arrivals:
raise DepartureWithoutArrivalError("Aucune arrivée ouverte")
if len(open_arrivals) > 1:
raise ArrivalAlreadyOpenError("Plusieurs arrivées sont ouvertes")
arrival = open_arrivals[0]
arrival_local = arrival.occurred_at.replace(tzinfo=timezone)
if occurred_local <= arrival_local:
raise InvalidPresenceEventError(
"L'instant du départ doit être postérieur à celui de l'arrivée"
)
slot = TimeSlot(
entry_id=arrival.entry_id, start_time=arrival.occurred_at.time(), end_time=wall_time
)
session.add(slot)
departure = WorkplacePresenceEvent(
idempotency_key=idempotency_key,
event_type="departure",
received_at=received_wall,
occurred_at=occurred_wall,
local_date=local_date,
entry_id=arrival.entry_id,
time_slot=slot,
processed_at=received_wall,
)
session.add(departure)
session.flush()
arrival.time_slot = slot
arrival.processed_at = received_wall
session.flush()
return _result(departure, replayed=False)
except IntegrityError as exc:
# Une autre requête peut avoir gagné la clé entre le contrôle et le flush.
session.rollback()
existing = session.scalar(
select(WorkplacePresenceEvent).where(
WorkplacePresenceEvent.idempotency_key == idempotency_key
)
)
if (
existing is not None
and existing.event_type == event_type
and existing.occurred_at == occurred_wall
):
return _result(existing, replayed=True)
raise IdempotencyConflictError("Conflit d'unicité lors de l'enregistrement") from exc
record_home_assistant_event = record_presence_event