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
|
||||||
163
tests/test_presence_service.py
Normal file
163
tests/test_presence_service.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.business.presence_service import (
|
||||||
|
ArrivalAlreadyOpenError,
|
||||||
|
DepartureWithoutArrivalError,
|
||||||
|
IdempotencyConflictError,
|
||||||
|
InvalidPresenceEventError,
|
||||||
|
record_presence_event,
|
||||||
|
)
|
||||||
|
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"default_day_type": "WORK",
|
||||||
|
"default_journey_profile_id": "moteur_seul",
|
||||||
|
"default_motor_vehicle_id": "citadine",
|
||||||
|
}
|
||||||
|
RECEIVED = datetime(2026, 8, 13, 7, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def call(session, event_type, occurred_at, key, **kwargs):
|
||||||
|
return record_presence_event(session, CONFIG, event_type, occurred_at, key, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arrival_creates_entry_with_defaults_without_commit(app):
|
||||||
|
with app.app_context():
|
||||||
|
result = call(
|
||||||
|
db.session, "arrival", "2026-08-13T08:23:10+02:00", "a-1", received_at=RECEIVED
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = db.session.get(WorkEntry, result.entry_id)
|
||||||
|
assert result.replayed is False
|
||||||
|
assert result.slot_state == "open"
|
||||||
|
assert (entry.day_type, entry.journey_profile_id, entry.motor_vehicle_id) == (
|
||||||
|
"WORK",
|
||||||
|
"moteur_seul",
|
||||||
|
"citadine",
|
||||||
|
)
|
||||||
|
assert db.session.query(WorkplacePresenceEvent).count() == 1
|
||||||
|
db.session.rollback()
|
||||||
|
assert db.session.query(WorkplacePresenceEvent).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_departure_completes_both_events(app):
|
||||||
|
with app.app_context():
|
||||||
|
arrival = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||||
|
departure = call(db.session, "departure", "2026-08-13T17:00:00+02:00", "d-1")
|
||||||
|
events = db.session.scalars(
|
||||||
|
db.select(WorkplacePresenceEvent).order_by(WorkplacePresenceEvent.id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
assert departure.time_slot_id is not None
|
||||||
|
assert departure.entry_id == arrival.entry_id
|
||||||
|
assert all(event.time_slot_id == departure.time_slot_id for event in events)
|
||||||
|
assert all(event.processed_at is not None for event in events)
|
||||||
|
assert db.session.get(TimeSlot, departure.time_slot_id).start_time.hour == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_pair_same_day_is_supported(app):
|
||||||
|
with app.app_context():
|
||||||
|
first = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||||
|
call(db.session, "departure", "2026-08-13T12:00:00+02:00", "d-1")
|
||||||
|
call(db.session, "arrival", "2026-08-13T13:00:00+02:00", "a-2")
|
||||||
|
second = call(db.session, "departure", "2026-08-13T17:00:00+02:00", "d-2")
|
||||||
|
|
||||||
|
assert second.entry_id == first.entry_id
|
||||||
|
assert db.session.query(TimeSlot).count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_arrival_is_global_across_days_and_does_not_create_entry(app):
|
||||||
|
with app.app_context():
|
||||||
|
call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||||
|
|
||||||
|
with pytest.raises(ArrivalAlreadyOpenError):
|
||||||
|
call(db.session, "arrival", "2026-08-14T08:00:00+02:00", "a-2")
|
||||||
|
|
||||||
|
assert db.session.query(WorkEntry).count() == 1
|
||||||
|
assert db.session.query(WorkplacePresenceEvent).count() == 1
|
||||||
|
assert db.session.query(TimeSlot).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"departure_at",
|
||||||
|
["2026-08-13T08:00:00+02:00", "2026-08-13T07:59:59+02:00"],
|
||||||
|
ids=["equal", "before"],
|
||||||
|
)
|
||||||
|
def test_invalid_departure_does_not_create_slot_or_event(app, departure_at):
|
||||||
|
with app.app_context():
|
||||||
|
call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPresenceEventError):
|
||||||
|
call(db.session, "departure", departure_at, "d-1")
|
||||||
|
|
||||||
|
assert db.session.query(TimeSlot).count() == 0
|
||||||
|
assert db.session.query(WorkplacePresenceEvent).count() == 1
|
||||||
|
assert (
|
||||||
|
db.session.query(WorkplacePresenceEvent).filter_by(event_type="departure").count() == 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("day_type", ["FORMATION", "GARDE"])
|
||||||
|
def test_existing_special_day_is_not_modified(app, day_type):
|
||||||
|
with app.app_context():
|
||||||
|
entry = WorkEntry(
|
||||||
|
date=date(2026, 8, 13),
|
||||||
|
day_type=day_type,
|
||||||
|
journey_profile_id="other-journey",
|
||||||
|
motor_vehicle_id="other-car",
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.flush()
|
||||||
|
result = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||||
|
db.session.refresh(entry)
|
||||||
|
|
||||||
|
assert result.entry_id == entry.id
|
||||||
|
assert (entry.day_type, entry.journey_profile_id, entry.motor_vehicle_id) == (
|
||||||
|
day_type,
|
||||||
|
"other-journey",
|
||||||
|
"other-car",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_transitions_and_payloads(app):
|
||||||
|
with app.app_context():
|
||||||
|
with pytest.raises(ArrivalAlreadyOpenError):
|
||||||
|
call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||||
|
call(db.session, "arrival", "2026-08-13T09:00:00+02:00", "a-2")
|
||||||
|
db.session.rollback()
|
||||||
|
with pytest.raises(DepartureWithoutArrivalError):
|
||||||
|
call(db.session, "departure", "2026-08-13T09:00:00+02:00", "d-1")
|
||||||
|
for event_type, occurred_at, key in (
|
||||||
|
("other", "2026-08-13T09:00:00+02:00", "x"),
|
||||||
|
("arrival", "2026-08-13T09:00:00", "x"),
|
||||||
|
("arrival", "not-a-date", "x"),
|
||||||
|
("arrival", "2026-08-13T09:00:00+02:00", ""),
|
||||||
|
):
|
||||||
|
with pytest.raises(InvalidPresenceEventError):
|
||||||
|
call(db.session, event_type, occurred_at, key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_departure_after_midnight_is_accepted_as_a_later_instant(app):
|
||||||
|
with app.app_context():
|
||||||
|
arrival = call(db.session, "arrival", "2026-08-13T23:30:00+00:00", "a-1")
|
||||||
|
departure = call(db.session, "departure", "2026-08-14T00:30:00+00:00", "d-1")
|
||||||
|
event = db.session.get(WorkplacePresenceEvent, arrival.event_id)
|
||||||
|
slot = db.session.get(TimeSlot, departure.time_slot_id)
|
||||||
|
|
||||||
|
assert event.occurred_at == datetime(2026, 8, 14, 1, 30)
|
||||||
|
assert event.local_date == date(2026, 8, 14)
|
||||||
|
assert (slot.start_time.hour, slot.end_time.hour) == (1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_and_key_conflict(app):
|
||||||
|
with app.app_context():
|
||||||
|
first = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "same")
|
||||||
|
replay = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "same")
|
||||||
|
assert replay.replayed is True
|
||||||
|
assert replay.event_id == first.event_id
|
||||||
|
with pytest.raises(IdempotencyConflictError):
|
||||||
|
call(db.session, "arrival", "2026-08-13T08:01:00+02:00", "same")
|
||||||
Reference in New Issue
Block a user