diff --git a/app/business/presence_service.py b/app/business/presence_service.py new file mode 100644 index 0000000..86aa1c1 --- /dev/null +++ b/app/business/presence_service.py @@ -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 diff --git a/tests/test_presence_service.py b/tests/test_presence_service.py new file mode 100644 index 0000000..5acc5df --- /dev/null +++ b/tests/test_presence_service.py @@ -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")