Compare commits

...

6 Commits

Author SHA1 Message Date
fc3250f431 docs(home-assistant): documenter l'intégration présence Home Assistant
Guide complet de configuration de l'intégration Home Assistant avec
l'API REST de présence : prérequis, secrets, rest_command, zones,
automatisations et dépannage.

Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-15 12:36:26 +02:00
bca00a06bf docs(api): documenter l'API REST (endpoints, exemples, schémas)
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-15 12:24:33 +02:00
704e73b936 test(api): couvrir l'édition Web d'une journée créée par l'API (étape 5)
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-15 12:11:56 +02:00
874328006a feat(api): exposer le blueprint HTTP sécurisé Home Assistant (étape 4)
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-15 12:03:53 +02:00
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
11 changed files with 1408 additions and 1 deletions

View File

@@ -18,12 +18,21 @@ import tomllib
from collections.abc import Mapping
import sqlalchemy as sa
from flask import Flask
from flask import Flask, request
from flask_sqlalchemy import 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):
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic).
@@ -182,9 +191,13 @@ def create_app(
app.config["HOME_ASSISTANT"] = get_home_assistant_config()
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["day_type_fr"] = _day_type_fr
from app.api import bp as api_bp
from app.routes.dashboard import bp as dashboard_bp
from app.routes.entries import bp as entries_bp
from app.routes.reports import bp as reports_bp
@@ -192,6 +205,13 @@ def create_app(
app.register_blueprint(dashboard_bp)
app.register_blueprint(entries_bp)
app.register_blueprint(reports_bp)
app.register_blueprint(api_bp)
@app.after_request
def add_api_cache_policy(response):
if request.path.startswith("/api/v1/"):
response.headers["Cache-Control"] = "no-store"
return response
with app.app_context():
_migrate_db(app)

166
app/api.py Normal file
View File

@@ -0,0 +1,166 @@
"""API JSON pour les événements de présence Home Assistant."""
from __future__ import annotations
import hmac
import os
from collections import defaultdict
from threading import Lock
from time import monotonic
from typing import Any
from flask import Blueprint, Response, current_app, jsonify, request
from app import db
from app.business.presence_service import (
ArrivalAlreadyOpenError,
DepartureWithoutArrivalError,
IdempotencyConflictError,
InvalidPresenceEventError,
record_presence_event,
)
bp = Blueprint("api", __name__, url_prefix="/api/v1")
_MAX_BODY_BYTES = 4096
_RATE_LIMIT = 30
_RATE_WINDOW_SECONDS = 60.0
_rate_lock = Lock()
_rate_requests: defaultdict[str, list[float]] = defaultdict(list)
def reset_rate_limiter() -> None:
"""Réinitialise la protection mémoire, notamment pour les tests."""
with _rate_lock:
_rate_requests.clear()
def _response(payload: dict[str, Any], status: int) -> Response:
response = jsonify(payload)
response.status_code = status
response.headers["Cache-Control"] = "no-store"
return response
def _error(status: int, message: str) -> Response:
return _response({"error": message}, status)
def _authenticated() -> tuple[str | None, Response | None]:
configured_token = os.environ.get("WORKLOG_API_TOKEN")
if not configured_token:
return None, _error(503, "API indisponible")
authorization = request.headers.get("Authorization", "")
scheme, separator, supplied_token = authorization.partition(" ")
if scheme.lower() != "bearer" or not separator or not supplied_token:
return None, _error(401, "Authentification requise")
if not hmac.compare_digest(supplied_token, configured_token):
return None, _error(401, "Authentification requise")
return supplied_token, None
def _rate_allowed(token: str) -> bool:
# Cette limite est par processus ; HAProxy ou une solution partagée est nécessaire
# pour garantir la limite à l'échelle de plusieurs workers.
now = monotonic()
with _rate_lock:
requests_for_token = _rate_requests[token]
requests_for_token[:] = [
timestamp for timestamp in requests_for_token if now - timestamp < _RATE_WINDOW_SECONDS
]
if len(requests_for_token) >= _RATE_LIMIT:
return False
requests_for_token.append(now)
return True
@bp.route("/workplace-presence", methods=["POST"])
def workplace_presence() -> Response:
"""Réceptionne un événement d'arrivée ou de départ authentifié."""
if current_app.config.get("HOME_ASSISTANT") is None:
return _error(404, "API indisponible")
token, authentication_error = _authenticated()
if authentication_error is not None:
return authentication_error
assert token is not None
if not _rate_allowed(token):
return _error(429, "Trop de requêtes")
if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
return _error(413, "Requête trop volumineuse")
raw_body = request.get_data(cache=True)
if len(raw_body) > _MAX_BODY_BYTES:
return _error(413, "Requête trop volumineuse")
if request.mimetype != "application/json":
return _error(415, "Content-Type invalide")
try:
payload = request.get_json(silent=False)
except Exception:
return _error(400, "JSON invalide")
if not isinstance(payload, dict):
return _error(422, "Schéma invalide")
allowed_keys = {"event", "occurred_at", "idempotency_key"}
if (
not set(payload).issubset(allowed_keys)
or "event" not in payload
or "occurred_at" not in payload
):
return _error(422, "Schéma invalide")
event = payload["event"]
occurred_at = payload["occurred_at"]
body_key = payload.get("idempotency_key")
header_key = request.headers.get("X-Idempotency-Key")
if body_key is not None and (not isinstance(body_key, str) or not body_key.strip()):
return _error(422, "Schéma invalide")
if header_key is not None and not header_key.strip():
return _error(422, "Schéma invalide")
if body_key is not None and header_key is not None and body_key != header_key:
return _error(422, "Schéma invalide")
idempotency_key = body_key if body_key is not None else header_key
if not all(
isinstance(value, str) and value.strip() for value in (event, occurred_at, idempotency_key)
):
return _error(422, "Schéma invalide")
try:
result = record_presence_event(
db.session,
current_app.config["HOME_ASSISTANT"],
event,
occurred_at,
idempotency_key,
)
db.session.commit()
except InvalidPresenceEventError:
db.session.rollback()
return _error(422, "Événement invalide")
except (ArrivalAlreadyOpenError, DepartureWithoutArrivalError, IdempotencyConflictError):
db.session.rollback()
return _error(409, "Conflit métier")
except Exception:
db.session.rollback()
return _error(500, "Erreur interne")
status = "replayed" if result.replayed else "created"
http_status = 200 if result.replayed or event == "departure" else 201
return _response(
{
"status": status,
"event_id": result.event_id,
"entry_id": result.entry_id,
"time_slot_id": result.time_slot_id,
"event_type": event,
},
http_status,
)
@bp.app_errorhandler(405)
def method_not_allowed(error):
if request.path.startswith("/api/v1/"):
return _error(405, "Méthode non autorisée")
return error

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(
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:
"""
@@ -86,6 +89,50 @@ class TimeSlot(db.Model):
end_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False)
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):

203
docs/api.md Normal file
View File

@@ -0,0 +1,203 @@
# Documentation de l'API REST (Intégration Présence)
Ce document fournit la documentation technique, autonome, exacte et vérifiable de l'API REST implémentée dans l'application **Tableau de bord pro**.
---
## 1. But et périmètre
L'API REST expose un endpoint unique destiné à recevoir des événements de présence automatisés (par exemple en provenance d'un système domotique comme Home Assistant). Elle permet de :
- Enregistrer l'**arrivée** d'un collaborateur sur son lieu de travail (création automatique d'une entrée de journée `WorkEntry` si elle n'existe pas et d'un événement `WorkplacePresenceEvent`).
- Enregistrer le **départ** du lieu de travail (fermeture de l'arrivée ouverte, création d'une plage horaire `TimeSlot` et de l'événement de départ associé).
- Garantir l'**idempotence** des requêtes via une clé unique pour éviter les doublons en cas de rejeu réseau.
---
## 2. Prérequis de configuration (`[home_assistant]`)
L'activation de l'API dépend de la présence et de la validité de la section `[home_assistant]` dans le fichier de configuration `config.toml`. Si cette section est absente, l'API est désactivée et renvoie un code `404`.
Exemple de configuration valide dans `config.toml` :
```toml
[home_assistant]
timezone = "Europe/Paris"
default_day_type = "WORK"
default_journey_profile_id = "moteur_seul"
default_motor_vehicle_id = "citadine"
```
### Règles de validation de la configuration :
- **`timezone`** : Doit être un fuseau horaire valide reconnu par `zoneinfo.ZoneInfo` (ex: `"Europe/Paris"`).
- **`default_day_type`** : Doit correspondre à un type de jour valide (`WORK`, `TT`, `GARDE`, `ASTREINTE`, `FORMATION`, `RTT`, `CONGE`, `MALADE`, `FERIE`).
- **`default_journey_profile_id`** : Doit être un identifiant de trajet existant dans la section `[journeys]`.
- **`default_motor_vehicle_id`** : Doit être un identifiant de véhicule existant dans la section `[vehicles]` dont le type est `"moteur"`.
---
## 3. Sécurité, Authentification et Transport
### Authentification par Jeton (Bearer Token)
- L'API exige un jeton d'authentification transmis via l'en-tête HTTP `Authorization` au format Bearer :
```http
Authorization: Bearer <WORKLOG_API_TOKEN>
```
- La variable d'environnement **`WORKLOG_API_TOKEN`** doit être définie sur le serveur d'exécution. Si cette variable n'est pas configurée, l'API refuse toute requête et renvoie le statut `503`.
- La comparaison du jeton fourni avec la valeur configurée est réalisée de manière sécurisée en temps constant via `hmac.compare_digest` pour prévenir les attaques temporelles.
### Transport et Proxy (HTTPS / HAProxy)
- L'application Flask n'implémente pas nativement le chiffrement TLS ni de mécanisme de rotation de jeton.
- En production, la sécurité du transport (HTTPS) et le routage sont entièrement délégués au serveur amont **HAProxy**.
---
## 4. Spécifications techniques de l'endpoint
- **URL** : `/api/v1/workplace-presence`
- **Méthode** : `POST`
- **Content-Type requis** : `application/json`
- **Taille maximale du corps** : `4096` octets (`_MAX_BODY_BYTES`). Au-delà, l'API rejette la requête avec le code `413`.
### Limitation de débit (Rate-Limit)
- Chaque jeton d'authentification est soumis à une limitation de débit par processus de **30 requêtes par fenêtre glissante de 60 secondes** (`_RATE_LIMIT = 30`, `_RATE_WINDOW_SECONDS = 60.0`).
- *Note d'architecture* : Ce compteur est strictement local au processus d'exécution Flask/Gunicorn. Dans un déploiement multi-workers, une limitation globale nécessite un mécanisme amont (tel que HAProxy) ou un stockage partagé.
---
## 5. Structure du Corps de la Requête (JSON)
Le corps de la requête doit être un objet JSON valide contenant les champs suivants :
| Champ | Type | Obligatoire | Description |
| :--- | :--- | :--- | :--- |
| `event` | `string` | Oui | Type d'événement : `"arrival"` ou `"departure"`. |
| `occurred_at` | `string` | Oui | Horodatage au format ISO 8601 comportant obligatoirement un offset de fuseau horaire (ex: `2026-08-13T08:00:00+02:00`). |
| `idempotency_key` | `string` | Oui | Clé d'idempotence unique (limitée à 255 caractères). **Obligatoire** : transmise soit dans le corps JSON (`idempotency_key`), soit via l'en-tête HTTP `X-Idempotency-Key`. |
### Règle sur la clé d'idempotence :
La clé d'idempotence est **obligatoire**. Elle doit être fournie soit dans le corps JSON (`idempotency_key`), soit via l'en-tête HTTP `X-Idempotency-Key`. Si la clé est fournie à la fois dans le corps JSON et dans l'en-tête, **leurs valeurs doivent être strictement identiques**, sous peine d'un rejet avec le code `422`. Ne jamais considérer ce champ comme facultatif.
---
## 6. Gestion du Temps et Règles Métier
- **Fuseau horaire** : Les horodatages `occurred_at` sont convertis dans le fuseau horaire configuré dans `[home_assistant].timezone` (la configuration explicite de `timezone` est obligatoire dans la section `[home_assistant]` ; `"Europe/Paris"` n'est qu'un exemple de valeur fourni dans la configuration de référence du projet).
- **Arrivée (`arrival`)** :
- Crée une entrée de journée (`WorkEntry`) à la date locale si elle n'existe pas, en appliquant les valeurs par défaut de la configuration (`default_day_type`, `default_journey_profile_id`, `default_motor_vehicle_id`).
- Une seule arrivée peut être ouverte (`time_slot_id IS NULL`) simultanément dans toute l'application.
- **Départ (`departure`)** :
- Ferme l'arrivée ouverte correspondante.
- Crée un intervalle de temps (`TimeSlot`) rattaché à l'entrée de journée, défini entre l'heure de l'arrivée et l'heure du départ.
- L'heure du départ doit être strictement postérieure à celle de l'arrivée locale (`occurred_local > arrival_local`), sinon un code `422` est renvoyé.
---
## 7. Réponses de Succès
### Codes de statut de succès :
- **`201 Created`** : Événement d'arrivée créé avec succès (nouvelle ressource).
- **`200 OK`** : Événement de départ enregistré avec succès, ou **rejeu (replay)** d'un événement existant identique (idempotence).
### Format de la réponse JSON :
```json
{
"status": "created",
"event_id": 42,
"entry_id": 12,
"time_slot_id": null,
"event_type": "arrival"
}
```
*Champs retournés :*
- `status` : `"created"` (nouvel enregistrement) ou `"replayed"` (requête idempotente rejouée).
- `event_id` : Identifiant unique de l'événement de présence enregistré.
- `entry_id` : Identifiant de la journée de travail associée (`WorkEntry`).
- `time_slot_id` : Identifiant de la plage horaire créée (`null` pour une arrivée non fermée, entier pour un départ).
- `event_type` : Rappel du type d'événement traité (`"arrival"` ou `"departure"`).
---
## 8. Gestion des Erreurs
L'API renvoie un format d'erreur homogène sous forme d'objet JSON :
```json
{
"error": "Message explicatif de l'erreur"
}
```
Toutes les réponses de l'API (succès et erreurs) comportent l'en-tête `Cache-Control: no-store`.
### Codes de statut HTTP d'erreur pris en charge :
| Code | Message d'erreur associé | Cause / Description |
| :--- | :--- | :--- |
| **`400`** | `"JSON invalide"` | Le corps de la requête n'est pas un JSON syntaxiquement valide. |
| **`401`** | `"Authentification requise"` | En-tête `Authorization` absent, schéma non Bearer, ou jeton invalide. |
| **`404`** | `"API indisponible"` | Section `[home_assistant]` absente de `config.toml`. |
| **`405`** | `"Méthode non autorisée"` | Utilisation d'une méthode HTTP autre que `POST` sur l'endpoint `/api/v1/...`. |
| **`409`** | `"Conflit métier"` | Conflit logique : tentative d'enregistrement d'un départ sans arrivée ouverte, double arrivée, ou réutilisation d'une clé d'idempotence avec des paramètres différents. |
| **`413`** | `"Requête trop volumineuse"` | La taille du corps de la requête dépasse la limite de 4096 octets. |
| **`415`** | `"Content-Type invalide"` | L'en-tête `Content-Type` est absent ou différent de `application/json`. |
| **`422`** | `"Schéma invalide"` ou `"Événement invalide"` | Données non conformes : clés non autorisées, format `occurred_at` invalide (absence d'offset ou ISO 8601 incorrect), types incorrects, ou départ antérieur à l'arrivée. |
| **`429`** | `"Trop de requêtes"` | Dépassement de la limite de débit (Rate-limit de 30 req / 60s par processus). |
| **`500`** | `"Erreur interne"` | Exception inattendue lors du traitement en base de données. |
| **`503`** | `"API indisponible"` | Variable d'environnement `WORKLOG_API_TOKEN` non définie sur le serveur. |
---
## 9. Idempotence
- L'API garantit l'idempotence par le biais de la **clé d'idempotence obligatoire** (`idempotency_key` dans le corps ou en-tête `X-Idempotency-Key`).
- Si une requête est rejouée avec une clé d'idempotence déjà enregistrée :
- Si les paramètres (`event` et `occurred_at`) sont strictement identiques, l'API renvoie le résultat précédent avec le statut `200 OK` et `"status": "replayed"`, sans dupliquer les enregistrements.
- Si les paramètres diffèrent pour une même clé, l'API rejette la requête avec le code `409` (`"Conflit métier"`).
---
## 10. Comportement de l'Interface Web après l'API
- Les journées (`WorkEntry`) créées automatiquement par l'API suite à une arrivée sont pleinement intégrées dans l'application.
- L'utilisateur peut consulter et modifier ces entrées via l'interface Web (formulaire d'édition `/entries/<id>/edit`) :
- Modification du type de journée (ex: passer de `WORK` à `FORMATION`), du profil de trajet ou du véhicule à moteur.
- Modification, ajout ou suppression de plages horaires (`TimeSlot`).
- *Cohérence des événements de présence (comportement validé par les tests d'intégration)* : Lorsque les plages horaires d'une entrée sont modifiées ou recréées depuis l'interface Web (formulaire d'édition), les événements de présence associés (`WorkplacePresenceEvent`) conservent leur traçabilité (rattachés à l'entrée `WorkEntry`), mais leurs identifiants de plage (`time_slot_id`) sont dissociés (`NULL`) pour refléter la réorganisation manuelle de la journée, conformément aux tests d'intégration existants. Aucune autre promesse au-delà de cette dissociation (`NULL`) et du maintien des événements n'est garantie.
---
## 11. Exemples d'utilisation `curl` sécurisés
> **Avertissement de sécurité** : N'écrivez jamais de secret en clair dans des scripts ou des documentations. Utilisez toujours une variable shell (ex: `$WORKLOG_API_TOKEN`).
### 1. Enregistrer une arrivée :
```bash
curl -i -X POST "http://localhost:5000/api/v1/workplace-presence" \
-H "Authorization: Bearer $WORKLOG_API_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: arrival-2026-08-13-01" \
-d '{
"event": "arrival",
"occurred_at": "2026-08-13T08:00:00+02:00"
}'
```
### 2. Enregistrer un départ :
```bash
curl -i -X POST "http://localhost:5000/api/v1/workplace-presence" \
-H "Authorization: Bearer $WORKLOG_API_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: departure-2026-08-13-01" \
-d '{
"event": "departure",
"occurred_at": "2026-08-13T17:00:00+02:00"
}'
```
---
## 12. Périmètre et Limites Connues
- **Fonctionnalités non implémentées** :
- Pas de support CORS (Cross-Origin Resource Sharing) pour des appels directs depuis un navigateur web.
- Pas de rotation automatique des jetons d'API.
- Pas de limite de débit distribuée (le rate-limit est confiné à chaque processus/worker Gunicorn).
- Pas d'import CSV en masse via l'API REST.
- **Exemple Home Assistant** : La configuration d'automatisation Home Assistant (YAML) n'est pas documentée dans ce fichier (cette documentation fait l'objet d'une étape ultérieure).

143
docs/home-assistant.md Normal file
View File

@@ -0,0 +1,143 @@
# Documentation Home Assistant (Intégration Présence)
Ce document fournit le guide complet et exact pour configurer une intégration Home Assistant avec l'API REST de présence du **Tableau de bord pro**. L'automatisation s'appuie sur le suivi d'une entité de type `person` et d'une zone géographique dédiée pour enregistrer automatiquement les arrivées et départs sur le lieu de travail.
---
## 1. Prérequis
Avant de configurer les automatisations dans Home Assistant, assurez-vous des éléments suivants :
1. **Zone géographique** : Home Assistant doit posséder une zone dont l'identifiant (ID) exact est `lieu_de_travail` (dans l'interface ou `zones.yaml`). Le nom de l'entité de zone doit correspondre à `zone.lieu_de_travail`.
2. **URL HTTPS** : L'application Flask (exposée via HAProxy en production) doit être accessible par Home Assistant via une URL HTTPS valide (ex: `https://tableau-de-bord-pro.example.com/api/v1/workplace-presence`).
3. **Jeton d'authentification (`secrets.yaml`)** : Le jeton secret (`WORKLOG_API_TOKEN`) généré pour l'API doit être stocké de manière sécurisée dans le fichier `secrets.yaml` de Home Assistant.
4. **Vérification SSL** : L'utilisation de `verify_ssl: true` est obligatoire pour garantir la sécurité des échanges HTTPS.
---
## 2. Configuration des Secrets (`secrets.yaml`)
Dans le fichier `secrets.yaml` de votre instance Home Assistant, ajoutez votre jeton d'API :
```yaml
worklog_api_token: "votre_jeton_api_securise_ici"
```
---
## 3. Configuration de la Commande REST (`rest_command`)
Déclarez la commande REST dans votre fichier `configuration.yaml` (ou dans votre fichier de configuration des commandes REST) pour permettre à Home Assistant d'effectuer les requêtes `POST` vers l'API.
```yaml
rest_command:
worklog_presence:
url: "https://tableau-de-bord-pro.example.com/api/v1/workplace-presence"
method: "post"
headers:
authorization: "Bearer !secret worklog_api_token"
content_type: "application/json"
payload: '{"event": "{{ event }}", "occurred_at": "{{ occurred_at }}", "idempotency_key": "{{ idempotency_key }}"}'
verify_ssl: true
```
---
## 4. Automations Home Assistant
Les deux automatisations ci-dessous surveillent l'entité `person.<nom>` (à adapter selon votre configuration, par exemple `person.antoine`) par rapport à la zone `zone.lieu_de_travail`.
### Règle d'idempotence et de cohérence temporelle :
Pour éviter toute divergence entre l'horodatage (`occurred_at`) et la clé d'idempotence (`idempotency_key`), les automatisations utilisent des variables locales basées sur l'horodatage du déclenchement (`trigger.to_state.last_updated`). **Ne jamais utiliser `now()` de manière éclatée ou divergente** à plusieurs endroits d'un même payload.
---
### A. Automation d'Arrivée
Cette automatisation se déclenche lorsque la personne entre dans la zone `lieu_de_travail` (l'état passe à `lieu_de_travail`).
```yaml
alias: "Travail - Enregistrer Arrivée"
description: "Enregistre automatiquement l'arrivée sur le lieu de travail via l'API REST."
trigger:
- platform: state
entity_id: person.antoine
to: "lieu_de_travail"
action:
- variables:
event_timestamp: "{{ trigger.to_state.last_updated.isoformat() }}"
idempoteno: "{{ trigger.to_state.last_updated.strftime('%Y%m%d-%H%M%S') }}"
idempotency: "ha-arrival-{{ idempoteno }}"
- service: rest_command.worklog_presence
response_variable: response
data:
event: "arrival"
occurred_at: "{{ event_timestamp }}"
idempotency_key: "{{ idempotency }}"
- choose:
- conditions: "{{ response.status not in [200, 201] }}"
sequence:
- service: persistent_notification.create
data:
title: "Échec Arrivée - Tableau de bord pro"
message: >
L'enregistrement de l'arrivée a échoué (Statut HTTP : {{ response.status }}).
Vérifiez les journaux de l'application.
mode: single
```
---
### B. Automation de Départ
Cette automatisation se déclenche lorsque la personne quitte la zone `lieu_de_travail` (l'état quitte `lieu_de_travail`, par exemple vers `not_home` ou un autre lieu).
```yaml
alias: "Travail - Enregistrer Départ"
description: "Enregistre automatiquement le départ du lieu de travail via l'API REST."
trigger:
- platform: state
entity_id: person.antoine
from: "lieu_de_travail"
action:
- variables:
event_timestamp: "{{ trigger.to_state.last_updated.isoformat() }}"
idempoteno: "{{ trigger.to_state.last_updated.strftime('%Y%m%d-%H%M%S') }}"
idempotency: "ha-departure-{{ idempoteno }}"
- service: rest_command.worklog_presence
response_variable: response
data:
event: "departure"
occurred_at: "{{ event_timestamp }}"
idempotency_key: "{{ idempotency }}"
- choose:
- conditions: "{{ response.status not in [200, 201] }}"
sequence:
- service: persistent_notification.create
data:
title: "Échec Départ - Tableau de bord pro"
message: >
L'enregistrement du départ a échoué (Statut HTTP : {{ response.status }}).
Vérifiez les journaux de l'application.
mode: single
```
---
## 5. Gestion des Réponses et de la Sécurité
- **Succès (200 / 201)** : Les codes de succès sont validés silencieusement. En cas de rejeu (réseau instable), l'API renvoie un statut `200` avec le statut `"replayed"`, ce qui est géré sans erreur par l'automatisation.
- **Erreurs et Notifications** : Si l'API renvoie un code d'erreur (`400`, `401`, `404`, `405`, `409`, `413`, `415`, `422`, `429`, `500`, `503`), une notification persistante est créée dans Home Assistant. **Le jeton d'authentification n'est jamais inclus** dans les messages de notification pour des raisons évidentes de sécurité.
---
## 6. Limites, Risques et Points de Vigilance
1. **Maximum d'une arrivée ouverte** : L'application métier n'autorise qu'une seule arrivée ouverte (`time_slot_id IS NULL`) simultanément. Si une arrivée précédente n'a pas été fermée (ou en cas de faux départ/réarrivée manqués), une tentative de nouvelle arrivée provoquera un conflit `409` (`ArrivalAlreadyOpenError`).
2. **Précision GPS et Oscillations (Doublons / Faux déclenchements)** : Les capteurs de géolocalisation (GPS mobile, intégration companion app) peuvent osciller aux abords de la zone `lieu_de_travail` (mauvaise réception GPS, rebonds d'antenne). Cela peut générer des micro-entrées et sorties successives. Bien que la clé d'idempotence et la règle chronologique protègent contre les doublons stricts basés sur le même horodatage, des entrées/sorties rapprochées multiples peuvent nécessiter des corrections manuelles dans l'interface web du Tableau de bord pro.
3. **Limitation de débit (Rate-limit)** : L'API applique une limite stricte de **30 requêtes par fenêtre de 60 secondes** par processus. En usage normal avec un unique tracker `person`, cette limite ne risque pas d'être atteinte, mais un dysfonctionnement d'automatisation en boucle pourrait déclencher un blocage `429`.
4. **Alignement des fuseaux horaires** : Le fuseau horaire configuré dans la section `[home_assistant]` de `config.toml` (ex: `Europe/Paris`) doit être cohérent avec celui de l'instance Home Assistant pour que les conversions d'horodatages ISO 8601 avec offset (`occurred_at`) s'alignent parfaitement avec les journées de travail locales.
5. **Disponibilité de l'API (404 / 503)** :
- Si la section `[home_assistant]` est absente de `config.toml`, l'API renvoie `404`.
- Si la variable d'environnement `WORKLOG_API_TOKEN` n'est pas définie sur le serveur Flask, l'API renvoie `503`.
6. **Sécurité et Traces Administrateur** : Les administrateurs de l'instance Home Assistant ont accès aux fichiers `secrets.yaml` et aux journaux de traces (traces d'exécution des automatisations). Veillez à ne jamais consigner le jeton d'API en clair dans les messages de log personnalisés ou les notifications.

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).
- **`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).
- **`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.
---

314
tests/test_api.py Normal file
View File

@@ -0,0 +1,314 @@
import re
import pytest
from app import db
from app.api import reset_rate_limiter
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
TOKEN = "test-api-token"
URL = "/api/v1/workplace-presence"
@pytest.fixture(autouse=True)
def api_state(monkeypatch):
monkeypatch.setenv("WORKLOG_API_TOKEN", TOKEN)
reset_rate_limiter()
yield
reset_rate_limiter()
def post(client, payload, **headers):
return client.post(URL, json=payload, headers={"Authorization": f"Bearer {TOKEN}", **headers})
def test_arrival_and_departure_create_expected_records(client, app):
arrival = post(
client,
{"event": "arrival", "occurred_at": "2026-08-13T08:00:00+02:00", "idempotency_key": "a"},
)
assert arrival.status_code == 201
assert arrival.json["status"] == "created"
assert arrival.json["time_slot_id"] is None
departure = post(
client,
{"event": "departure", "occurred_at": "2026-08-13T17:00:00+02:00", "idempotency_key": "d"},
)
assert departure.status_code == 200
assert departure.json["time_slot_id"] is not None
with app.app_context():
assert db.session.query(TimeSlot).count() == 1
def test_arrival_entry_can_be_fully_edited_from_web_form(client, app):
arrival = post(
client,
{
"event": "arrival",
"occurred_at": "2026-08-13T08:00:00+02:00",
"idempotency_key": "web-edit-arrival",
},
)
assert arrival.status_code == 201
entry_id = arrival.json["entry_id"]
form = client.get(f"/entries/{entry_id}/edit")
assert form.status_code == 200
assert "Véhicule à moteur seul" in form.text
assert re.search(
r'<option value="moteur_seul".*?data-has-motor="true".*?selected',
form.text,
re.DOTALL,
)
assert "Citadine électrique" in form.text
assert re.search(r'name="motor_vehicle_id" value="citadine".*?checked', form.text, re.DOTALL)
assert 'name="start_time"' in form.text
assert 'name="end_time"' in form.text
formation = client.post(
f"/entries/{entry_id}/edit",
data={
"date": "2026-08-13",
"day_type": "FORMATION",
"journey_profile_id": "moteur_velo",
"motor_vehicle_id": "familiale",
"start_time": ["08:00"],
"end_time": ["12:00"],
"comment": "Formation",
},
)
assert formation.status_code == 302
with app.app_context():
entry = db.session.get(WorkEntry, entry_id)
assert entry.day_type == "FORMATION"
assert entry.journey_profile_id == "moteur_velo"
assert entry.motor_vehicle_id == "familiale"
assert [(slot.start_time.hour, slot.end_time.hour) for slot in entry.time_slots] == [
(8, 12)
]
garde = client.post(
f"/entries/{entry_id}/edit",
data={
"date": "2026-08-13",
"day_type": "GARDE",
"journey_profile_id": "moteur_seul",
"motor_vehicle_id": "moto",
"start_time": ["08:00", "13:00", ""],
"end_time": ["12:00", "17:00", ""],
"comment": "Garde",
},
)
assert garde.status_code == 302
with app.app_context():
entry = db.session.get(WorkEntry, entry_id)
assert entry.day_type == "GARDE"
assert entry.journey_profile_id == "moteur_seul"
assert entry.motor_vehicle_id == "moto"
assert [(slot.start_time.hour, slot.end_time.hour) for slot in entry.time_slots] == [
(8, 12),
(13, 17),
]
event = db.session.scalar(
db.select(WorkplacePresenceEvent).where(
WorkplacePresenceEvent.idempotency_key == "web-edit-arrival"
)
)
assert event.entry_id == entry_id
assert event.time_slot_id is None
empty_slots = client.post(
f"/entries/{entry_id}/edit",
data={
"date": "2026-08-13",
"day_type": "GARDE",
"journey_profile_id": "moteur_seul",
"motor_vehicle_id": "moto",
"start_time": [""],
"end_time": [""],
"comment": "Garde sans plage",
},
)
assert empty_slots.status_code == 302
with app.app_context():
entry = db.session.get(WorkEntry, entry_id)
assert entry.time_slots == []
assert entry.day_type == "GARDE"
assert db.session.query(WorkplacePresenceEvent).count() == 1
def test_web_edit_recreates_slots_without_losing_arrival_departure_events(client, app):
arrival = post(
client,
{
"event": "arrival",
"occurred_at": "2026-08-14T08:00:00+02:00",
"idempotency_key": "recreate-arrival",
},
)
departure = post(
client,
{
"event": "departure",
"occurred_at": "2026-08-14T17:00:00+02:00",
"idempotency_key": "recreate-departure",
},
)
assert arrival.status_code == 201
assert departure.status_code == 200
entry_id = arrival.json["entry_id"]
response = client.post(
f"/entries/{entry_id}/edit",
data={
"date": "2026-08-14",
"day_type": "FORMATION",
"journey_profile_id": "moteur_velo",
"motor_vehicle_id": "familiale",
"start_time": ["09:00", "13:00", ""],
"end_time": ["12:00", "17:00", ""],
"comment": "Plages recréées",
},
)
assert response.status_code == 302
with app.app_context():
entry = db.session.get(WorkEntry, entry_id)
assert len(entry.time_slots) == 2
events = db.session.scalars(
db.select(WorkplacePresenceEvent)
.where(WorkplacePresenceEvent.entry_id == entry_id)
.order_by(WorkplacePresenceEvent.event_type)
).all()
assert {event.idempotency_key for event in events} == {
"recreate-arrival",
"recreate-departure",
}
assert all(event.time_slot_id is None for event in events)
def test_replay_does_not_duplicate(client, app):
payload = {
"event": "arrival",
"occurred_at": "2026-08-13T08:00:00+02:00",
"idempotency_key": "same",
}
assert post(client, payload).status_code == 201
replay = post(client, payload)
assert replay.status_code == 200
assert replay.json["status"] == "replayed"
with app.app_context():
assert db.session.query(WorkplacePresenceEvent).count() == 1
def test_conflicts_and_idempotency_header_mismatch(client):
departure = post(
client,
{"event": "departure", "occurred_at": "2026-08-13T08:00:00+02:00", "idempotency_key": "d"},
)
assert departure.status_code == 409
mismatch = post(
client,
{"event": "arrival", "occurred_at": "2026-08-13T09:00:00+02:00", "idempotency_key": "body"},
**{"X-Idempotency-Key": "header"},
)
assert mismatch.status_code == 422
assert (
post(
client,
{
"event": "arrival",
"occurred_at": "2026-08-13T09:00:00+02:00",
"idempotency_key": "a",
},
).status_code
== 201
)
assert (
post(
client,
{
"event": "arrival",
"occurred_at": "2026-08-13T10:00:00+02:00",
"idempotency_key": "b",
},
).status_code
== 409
)
@pytest.mark.parametrize(
("payload", "status"),
[
({"event": "arrival", "occurred_at": "bad", "idempotency_key": "a"}, 422),
({"event": "arrival", "occurred_at": "2026-08-13T08:00:00+02:00", "extra": "x"}, 422),
(
{
"event": "arrival",
"occurred_at": "2026-08-13T08:00:00+02:00",
"idempotency_key": "a",
},
201,
),
],
)
def test_validation_and_header_idempotency(client, payload, status):
headers = {"X-Idempotency-Key": "a"} if "idempotency_key" not in payload else {}
response = post(client, payload, **headers)
assert response.status_code == status
def test_authentication_and_disabled_config(client, app, monkeypatch):
monkeypatch.delenv("WORKLOG_API_TOKEN")
assert post(client, {}).status_code == 503
monkeypatch.setenv("WORKLOG_API_TOKEN", TOKEN)
assert client.post(URL).status_code == 401
app.config["HOME_ASSISTANT"] = None
assert post(client, {}).status_code == 404
def test_http_errors_are_json_uncached_and_do_not_leak(client):
response = client.post(
URL, data="{}", content_type="text/plain", headers={"Authorization": f"Bearer {TOKEN}"}
)
assert response.status_code == 415
assert response.headers["Cache-Control"] == "no-store"
assert "traceback" not in response.get_data(as_text=True).lower()
assert TOKEN not in response.get_data(as_text=True)
malformed = client.post(
URL,
data="{",
content_type="application/json",
headers={"Authorization": f"Bearer {TOKEN}"},
)
assert malformed.status_code == 400
assert client.get(URL).status_code == 405
def test_body_limit_and_rate_limit(client):
oversized = post(
client,
{
"event": "arrival",
"occurred_at": "2026-08-13T08:00:00+02:00",
"idempotency_key": "x" * 4020,
},
)
assert oversized.status_code == 413
reset_rate_limiter()
for index in range(30):
response = post(
client,
{
"event": "arrival",
"occurred_at": "2026-08-13T08:00:00+02:00",
"idempotency_key": f"rate-{index}",
},
)
assert response.status_code != 429
assert post(client, {}).status_code == 429

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 isinstance(db.engine.pool, StaticPool)
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")