Compare commits
1 Commits
e9f73c7562
...
api-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a27f282b69 |
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