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:
227
app/business/presence_service.py
Normal file
227
app/business/presence_service.py
Normal 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
|
||||
Reference in New Issue
Block a user