2 Commits

Author SHA1 Message Date
a27f282b69 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>
2026-08-13 18:54:33 +02:00
e9f73c7562 feat: persister les événements de présence et activer les clés étrangères SQLite
Ajoute le modèle WorkplacePresenceEvent pour stocker les événements de
présence reçus de Home Assistant, avec clé d'idempotence unique, lien vers
une journée et, facultativement, une plage horaire. Active les contraintes
de clés étrangères sur chaque connexion SQLite et documente le schéma dans
l'onboarding. Couvre le tout par des tests de modèle et de factory.

Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-13 17:36:26 +02:00
7 changed files with 573 additions and 0 deletions

View File

@@ -24,6 +24,15 @@ from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy() db = SQLAlchemy()
def _enable_sqlite_foreign_keys(dbapi_connection, connection_record):
"""Active les contraintes de clés étrangères sur chaque connexion SQLite."""
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA foreign_keys=ON")
finally:
cursor.close()
def _migrate_db(app): def _migrate_db(app):
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic). """Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic).
@@ -182,6 +191,9 @@ def create_app(
app.config["HOME_ASSISTANT"] = get_home_assistant_config() app.config["HOME_ASSISTANT"] = get_home_assistant_config()
db.init_app(app) db.init_app(app)
with app.app_context():
if db.engine.dialect.name == "sqlite":
sa.event.listen(db.engine, "connect", _enable_sqlite_foreign_keys)
app.jinja_env.filters["date_fr"] = _date_fr app.jinja_env.filters["date_fr"] = _date_fr
app.jinja_env.filters["day_type_fr"] = _day_type_fr app.jinja_env.filters["day_type_fr"] = _day_type_fr

View 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

View File

@@ -37,6 +37,9 @@ class WorkEntry(db.Model):
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship( time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
back_populates="entry", cascade="all, delete-orphan", order_by="TimeSlot.start_time" back_populates="entry", cascade="all, delete-orphan", order_by="TimeSlot.start_time"
) )
presence_events: so.Mapped[list["WorkplacePresenceEvent"]] = so.relationship(
back_populates="entry", cascade="all, delete-orphan"
)
def total_minutes(self) -> int: def total_minutes(self) -> int:
""" """
@@ -86,6 +89,50 @@ class TimeSlot(db.Model):
end_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False) end_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False)
entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="time_slots") entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="time_slots")
presence_events: so.Mapped[list["WorkplacePresenceEvent"]] = so.relationship(
back_populates="time_slot", passive_deletes=True
)
class WorkplacePresenceEvent(db.Model):
"""Événement de présence reçu de Home Assistant.
``received_at`` est un instant normalisé en UTC, stocké naïf selon la convention
actuelle de l'application. À l'inverse, ``occurred_at`` est l'heure murale naïve
dans ``Europe/Paris`` et ``local_date`` est le jour local dérivé de cette heure.
Cette distinction est volontaire : elle sera utilisée par le service métier futur
pour rattacher les arrivées et départs aux journées, notamment autour de minuit.
"""
__tablename__ = "workplace_presence_events"
__table_args__ = (
sa.CheckConstraint("event_type IN ('arrival', 'departure')", name="ck_presence_event_type"),
sa.Index("ix_presence_events_local_date", "local_date"),
sa.Index("ix_presence_events_entry_id", "entry_id"),
)
id: so.Mapped[int] = so.mapped_column(primary_key=True)
idempotency_key: so.Mapped[str] = so.mapped_column(sa.String(255), unique=True, nullable=False)
event_type: so.Mapped[str] = so.mapped_column(sa.String(9), nullable=False)
received_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, nullable=False)
# Heure locale Europe/Paris, sans fuseau : ne pas la traiter comme un instant UTC.
occurred_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, nullable=False)
local_date: so.Mapped[date] = so.mapped_column(sa.Date, nullable=False)
entry_id: so.Mapped[int] = so.mapped_column(
sa.ForeignKey("work_entries.id", ondelete="CASCADE"), nullable=False
)
time_slot_id: so.Mapped[int | None] = so.mapped_column(
sa.ForeignKey("time_slots.id", ondelete="SET NULL"), nullable=True
)
created_at: so.Mapped[datetime] = so.mapped_column(
sa.DateTime, default=lambda: datetime.now(UTC), nullable=False
)
processed_at: so.Mapped[datetime | None] = so.mapped_column(sa.DateTime, nullable=True)
entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="presence_events")
time_slot: so.Mapped["TimeSlot | None"] = so.relationship(
back_populates="presence_events", passive_deletes=True
)
class LeaveBalance(db.Model): class LeaveBalance(db.Model):

View File

@@ -120,6 +120,14 @@ La base de données stocke l'activité saisie par l'utilisateur :
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps). - **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
- **`time_slots`** : Les plages horaires travaillées associées à une journée (heure de début, heure de fin, ID de l'entrée). - **`time_slots`** : Les plages horaires travaillées associées à une journée (heure de début, heure de fin, ID de l'entrée).
- **`leave_balance`** : Les quotas annuels de congés et de RTT (année, total congés, total RTT). - **`leave_balance`** : Les quotas annuels de congés et de RTT (année, total congés, total RTT).
- **`workplace_presence_events`** : Les événements reçus de Home Assistant, avec une clé d'idempotence unique et leurs liens vers une journée et, facultativement, une plage horaire.
Les événements de présence stockent `received_at` comme un timestamp UTC naïf,
conformément à la convention existante des métadonnées. `occurred_at` est différent :
il représente une heure locale Europe/Paris naïve, et `local_date` est le jour local
qui en est dérivé. Une arrivée non encore rattachée à une plage est identifiée par
`event_type = "arrival"` et `time_slot_id IS NULL`; la signification de
`processed_at` sera précisée par le service métier de l'étape suivante.
--- ---

View File

@@ -9,3 +9,8 @@ def test_app_fixture_uses_one_in_memory_database_connection(app):
assert str(db.engine.url) == "sqlite:///:memory:" assert str(db.engine.url) == "sqlite:///:memory:"
assert isinstance(db.engine.pool, StaticPool) assert isinstance(db.engine.pool, StaticPool)
assert sa.inspect(db.engine).has_table("work_entries") assert sa.inspect(db.engine).has_table("work_entries")
def test_sqlite_foreign_keys_are_enabled(app):
with app.app_context():
assert db.session.scalar(sa.text("PRAGMA foreign_keys")) == 1

111
tests/test_models.py Normal file
View File

@@ -0,0 +1,111 @@
from datetime import date, datetime, time
import pytest
import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
from app import db
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
def make_entry() -> WorkEntry:
return WorkEntry(date=date(2026, 8, 13), day_type="WORK")
def make_event(entry: WorkEntry, key: str = "ha-arrival-1") -> WorkplacePresenceEvent:
return WorkplacePresenceEvent(
idempotency_key=key,
event_type="arrival",
received_at=datetime(2026, 8, 13, 6, 23, 10),
occurred_at=datetime(2026, 8, 13, 8, 23, 10),
local_date=date(2026, 8, 13),
entry=entry,
)
def test_presence_event_creation_and_relations(app):
with app.app_context():
entry = make_entry()
slot = TimeSlot(start_time=time(8), end_time=time(12), entry=entry)
event = make_event(entry)
event.time_slot = slot
db.session.add(entry)
db.session.commit()
assert event.entry is entry
assert event in entry.presence_events
assert event.time_slot is slot
assert event in slot.presence_events
assert event.processed_at is None
def test_idempotency_key_is_unique(app):
with app.app_context():
entry = make_entry()
db.session.add_all([entry, make_event(entry), make_event(entry, "ha-arrival-1")])
with pytest.raises(IntegrityError):
db.session.commit()
db.session.rollback()
def test_event_type_check_constraint(app):
with app.app_context():
entry = make_entry()
event = make_event(entry)
event.event_type = "unknown"
db.session.add_all([entry, event])
with pytest.raises(IntegrityError):
db.session.commit()
db.session.rollback()
def test_time_slot_link_is_nullable_and_set_null_on_slot_delete(app):
with app.app_context():
entry = make_entry()
slot = TimeSlot(start_time=time(8), end_time=time(12), entry=entry)
event = make_event(entry)
event.time_slot = slot
db.session.add(entry)
db.session.commit()
db.session.delete(slot)
db.session.commit()
assert db.session.get(WorkplacePresenceEvent, event.id).time_slot_id is None
def test_events_cascade_when_work_entry_is_deleted(app):
with app.app_context():
entry = make_entry()
db.session.add(make_event(entry))
db.session.commit()
event_id = entry.presence_events[0].id
db.session.delete(entry)
db.session.commit()
assert db.session.get(WorkplacePresenceEvent, event_id) is None
def test_create_all_adds_presence_table_without_losing_existing_entries(app):
with app.app_context():
db.session.add(make_entry())
db.session.commit()
db.session.execute(sa.text("DROP TABLE workplace_presence_events"))
db.session.commit()
db.create_all()
assert db.session.scalar(sa.select(sa.func.count()).select_from(WorkEntry)) == 1
assert sa.inspect(db.engine).has_table("workplace_presence_events")
def test_create_all_creates_all_tables_on_empty_sqlite_database(app):
with app.app_context():
db.drop_all()
db.create_all()
inspector = sa.inspect(db.engine)
assert inspector.has_table("work_entries")
assert inspector.has_table("time_slots")
assert inspector.has_table("workplace_presence_events")

View 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")