É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>
228 lines
8.9 KiB
Python
228 lines
8.9 KiB
Python
"""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
|