Compare commits
5 Commits
a27f282b69
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
b7bde134aa
|
|||
| fc3250f431 | |||
| bca00a06bf | |||
| 704e73b936 | |||
| 874328006a |
13
AGENTS.md
13
AGENTS.md
@@ -39,6 +39,7 @@ grep -A 1 -B 1 subagent /home/antoine/.config/opencode/opencode.json
|
|||||||
## Variables d'environnement
|
## Variables d'environnement
|
||||||
|
|
||||||
- `SECRET_KEY` : requis en production (défaut `dev-secret-change-in-prod` en dev)
|
- `SECRET_KEY` : requis en production (défaut `dev-secret-change-in-prod` en dev)
|
||||||
|
- `WORKLOG_API_TOKEN` : requis en production pour l'authentification par jeton Bearer de l'API de présence Home Assistant (`/api/v1/workplace-presence`)
|
||||||
|
|
||||||
## Git & Conventions de Commit
|
## Git & Conventions de Commit
|
||||||
|
|
||||||
@@ -54,10 +55,10 @@ grep -A 1 -B 1 subagent /home/antoine/.config/opencode/opencode.json
|
|||||||
Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The DB is SQLite via SQLAlchemy, stored in `instance/worklog.db`. All vehicle/journey/tax configuration lives in `config.toml` (loaded at startup into `app.config["TOML"]`), not in the database.
|
Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The DB is SQLite via SQLAlchemy, stored in `instance/worklog.db`. All vehicle/journey/tax configuration lives in `config.toml` (loaded at startup into `app.config["TOML"]`), not in the database.
|
||||||
|
|
||||||
**Data flow:**
|
**Data flow:**
|
||||||
- `config.toml` → `app/config_loader.py` → accessed via `get_vehicles()`, `get_journeys()`, `get_bareme(year, cv)`
|
- `config.toml` → `app/config_loader.py` → accessed via `get_vehicles()`, `get_journeys()`, `get_bareme(year, cv)`, `get_home_assistant_config()`
|
||||||
- `app/models.py` defines `WorkEntry` (one row per day), `TimeSlot` (N plages horaires per entry), `LeaveBalance` (annual quotas)
|
- `app/models.py` defines `WorkEntry` (one row per day), `TimeSlot` (N plages horaires per entry), `LeaveBalance` (annual quotas), `WorkplacePresenceEvent` (presence tracking events)
|
||||||
- `app/business/` contains pure functions with no Flask dependencies: `time_calc.py` (minutes/reference), `travel_calc.py` (km, CO2, frais réels), `leave_calc.py` (solde congés/RTT)
|
- `app/business/` contains pure functions with no Flask dependencies: `time_calc.py` (minutes/reference), `travel_calc.py` (km, CO2, frais réels), `leave_calc.py` (solde congés/RTT), `presence_service.py` (presence event processing, idempotency, arrival/departure rules)
|
||||||
- Routes in `app/routes/` use business functions and config_loader, then render Jinja2 templates
|
- Routes in `app/routes/` and API in `app/api.py` use business functions and config_loader, then render Jinja2 templates or return JSON responses
|
||||||
|
|
||||||
**Key domain rules:**
|
**Key domain rules:**
|
||||||
- Day types: `WORK | TT | GARDE | ASTREINTE | FORMATION | RTT | CONGE | MALADE | FERIE`
|
- Day types: `WORK | TT | GARDE | ASTREINTE | FORMATION | RTT | CONGE | MALADE | FERIE`
|
||||||
@@ -70,9 +71,9 @@ Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The D
|
|||||||
|
|
||||||
**Tailwind CDN limitation:** Dynamic Jinja2 classes (e.g. `class="{{ var }}"`) are not included by the CDN. Use `style=` inline for dynamic colors.
|
**Tailwind CDN limitation:** Dynamic Jinja2 classes (e.g. `class="{{ var }}"`) are not included by the CDN. Use `style=` inline for dynamic colors.
|
||||||
|
|
||||||
**Auth:** Handled entirely by HAProxy upstream. The app has no authentication.
|
**Auth:** Web interface handled entirely by HAProxy upstream (no authentication in app). Workplace presence API (`/api/v1/workplace-presence`) uses Bearer token authentication validated via the `WORKLOG_API_TOKEN` environment variable and `hmac.compare_digest`.
|
||||||
|
|
||||||
**Tests:** `tests/conftest.py` provides `app` and `client` fixtures using an in-memory SQLite DB and a temporary TOML config file. Business logic tests (`test_time_calc.py`, `test_travel_calc.py`) have no Flask dependencies and need no fixtures.
|
**Tests:** `tests/conftest.py` provides `app` and `client` fixtures using an in-memory SQLite DB and a temporary TOML config file. Business logic tests (`test_time_calc.py`, `test_travel_calc.py`, `test_presence_service.py`) and API tests (`test_api.py`) have no Flask dependencies or use client fixtures respectively.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Application web personnelle de suivi du temps de travail et des déplacements pr
|
|||||||
- **Solde congés / RTT** : suivi des jours posés et du solde restant
|
- **Solde congés / RTT** : suivi des jours posés et du solde restant
|
||||||
- **Rapports annuels** : kilométrage total, frais réels déductibles, répartition par type de journée
|
- **Rapports annuels** : kilométrage total, frais réels déductibles, répartition par type de journée
|
||||||
- **Véhicules électriques** : majoration de 20 % appliquée automatiquement sur les frais réels
|
- **Véhicules électriques** : majoration de 20 % appliquée automatiquement sur les frais réels
|
||||||
|
- **Intégration présence Home Assistant** : automatisation des arrivées et départs via une API REST dédiée sécurisée par jeton Bearer
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -67,6 +68,9 @@ Toute la configuration métier se trouve dans `config.toml` :
|
|||||||
- **`[vehicles.*]`** : véhicules avec puissance fiscale, type de carburant et émissions CO₂
|
- **`[vehicles.*]`** : véhicules avec puissance fiscale, type de carburant et émissions CO₂
|
||||||
- **`[journeys.*]`** : profils de trajet avec distances par véhicule
|
- **`[journeys.*]`** : profils de trajet avec distances par véhicule
|
||||||
- **`[bareme_kilometrique.YYYY.*]`** : barème fiscal par année et puissance (à mettre à jour chaque année)
|
- **`[bareme_kilometrique.YYYY.*]`** : barème fiscal par année et puissance (à mettre à jour chaque année)
|
||||||
|
- **`[home_assistant]`** : paramètres optionnels de l'API de présence (fuseau horaire, valeurs par défaut des entrées créées automatiquement)
|
||||||
|
|
||||||
|
En production, l'API de présence nécessite également la configuration de la variable d'environnement **`WORKLOG_API_TOKEN`**.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import tomllib
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Flask
|
from flask import Flask, request
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
@@ -197,6 +197,7 @@ def create_app(
|
|||||||
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
|
||||||
|
|
||||||
|
from app.api import bp as api_bp
|
||||||
from app.routes.dashboard import bp as dashboard_bp
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
from app.routes.entries import bp as entries_bp
|
from app.routes.entries import bp as entries_bp
|
||||||
from app.routes.reports import bp as reports_bp
|
from app.routes.reports import bp as reports_bp
|
||||||
@@ -204,6 +205,13 @@ def create_app(
|
|||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(entries_bp)
|
app.register_blueprint(entries_bp)
|
||||||
app.register_blueprint(reports_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():
|
with app.app_context():
|
||||||
_migrate_db(app)
|
_migrate_db(app)
|
||||||
|
|||||||
166
app/api.py
Normal file
166
app/api.py
Normal 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
|
||||||
205
docs/api.md
Normal file
205
docs/api.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# 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 (corps ou en-tête) | 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 **de manière globale à 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 et conservation des événements de présence (comportement validé par les tests d'intégration)* :
|
||||||
|
- **Conservation** : Les événements de présence (`WorkplacePresenceEvent`) sont conservés en base de données **tant que l'entrée de journée associée (`WorkEntry`) n'est pas supprimée** (liaison soumise à la suppression en cascade `cascade="all, delete-orphan"` au niveau du modèle de données).
|
||||||
|
- **Dissociation de plage** : 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é et leur rattachement à 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`), du maintien conditionnel (tant que `WorkEntry` existe) et du suivi 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.
|
||||||
|
- **Intégration Home Assistant** : La configuration détaillée des scripts, secrets, commandes REST et automatisations Home Assistant est documentée dans la [Documentation Home Assistant](home-assistant.md).
|
||||||
148
docs/home-assistant.md
Normal file
148
docs/home-assistant.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# 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 en veillant à ce que sa valeur **inclue obligatoirement le préfixe exact `Bearer `** (suivi d'un espace et de votre jeton secret) :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
worklog_api_bearer_token: "Bearer votre_jeton_api_securise_ici"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Attention (point critique)** : Ne préfixez jamais par `Bearer ` dans la section `rest_command`. La commande utilise `authorization: !secret worklog_api_bearer_token`, ce qui injecte directement la valeur complète définie dans `secrets.yaml` (qui doit donc impérativement commencer par `Bearer `).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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"
|
||||||
|
content_type: "application/json"
|
||||||
|
headers:
|
||||||
|
authorization: !secret worklog_api_bearer_token
|
||||||
|
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 != 200 }}"
|
||||||
|
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 :
|
||||||
|
- **Arrivée** : Renvoie `201 Created` lors d'une nouvelle création, ou `200 OK` en cas de rejeu (requête idempotente).
|
||||||
|
- **Départ** : Renvoie strictement `200 OK` (qu'il s'agisse d'un nouvel enregistrement de départ ou d'un rejeu).
|
||||||
|
En cas de rejeu ou de succès normal, la réponse est gérée sans erreur par les automatisations.
|
||||||
|
- **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 (globale)** : L'application métier n'autorise qu'une seule arrivée ouverte (`time_slot_id IS NULL`) simultanément **à l'échelle globale de l'application**. 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.
|
||||||
@@ -101,7 +101,7 @@ Ce fichier contient les paramètres qui ne changent pas fréquemment et qui déf
|
|||||||
- **Véhicules (`[vehicles.*]`)** : Nom, type (moteur ou velo), carburant (electric, diesel, essence, none), émissions de CO₂ par km, et puissance fiscale (CV).
|
- **Véhicules (`[vehicles.*]`)** : Nom, type (moteur ou velo), carburant (electric, diesel, essence, none), émissions de CO₂ par km, et puissance fiscale (CV).
|
||||||
- **Trajets (`[journeys.*]`)** : Profils de trajets prédéfinis (ex: "moteur_seul", "moteur_velo") avec les distances associées par type de moyen de transport.
|
- **Trajets (`[journeys.*]`)** : Profils de trajets prédéfinis (ex: "moteur_seul", "moteur_velo") avec les distances associées par type de moyen de transport.
|
||||||
- **Barème kilométrique (`[bareme_kilometrique.YYYY.*]`)** : Les tranches fiscales officielles de remboursement par année et par puissance fiscale (CV).
|
- **Barème kilométrique (`[bareme_kilometrique.YYYY.*]`)** : Les tranches fiscales officielles de remboursement par année et par puissance fiscale (CV).
|
||||||
- **Home Assistant (`[home_assistant]`)** : Les valeurs par défaut et le fuseau utilisés par la future API de présence :
|
- **Home Assistant (`[home_assistant]`)** : Les valeurs par défaut et le fuseau utilisés par l'API de présence (implémentée dans `app/api.py` et `app/business/presence_service.py`) :
|
||||||
```toml
|
```toml
|
||||||
[home_assistant]
|
[home_assistant]
|
||||||
timezone = "Europe/Paris"
|
timezone = "Europe/Paris"
|
||||||
@@ -113,7 +113,7 @@ Ce fichier contient les paramètres qui ne changent pas fréquemment et qui déf
|
|||||||
|
|
||||||
*Note : Ces données sont chargées en mémoire au démarrage de l'application dans `app.config["TOML"]`.*
|
*Note : Ces données sont chargées en mémoire au démarrage de l'application dans `app.config["TOML"]`.*
|
||||||
|
|
||||||
Le secret de la future API ne doit pas être ajouté au fichier TOML. Il proviendra de la variable d'environnement `WORKLOG_API_TOKEN` lorsqu'elle sera implémentée ; cette étape ne le stocke ni ne le gère.
|
Le secret de l'API de présence ne doit pas être ajouté au fichier TOML. Il est configuré via la variable d'environnement `WORKLOG_API_TOKEN` sur le serveur d'exécution.
|
||||||
|
|
||||||
### SQLite / `instance/worklog.db` (Données dynamiques)
|
### SQLite / `instance/worklog.db` (Données dynamiques)
|
||||||
La base de données stocke l'activité saisie par l'utilisateur :
|
La base de données stocke l'activité saisie par l'utilisateur :
|
||||||
@@ -126,8 +126,7 @@ Les événements de présence stockent `received_at` comme un timestamp UTC naï
|
|||||||
conformément à la convention existante des métadonnées. `occurred_at` est différent :
|
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
|
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
|
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
|
`event_type = "arrival"` et `time_slot_id IS NULL`; `processed_at` enregistre l'instant de traitement effectif de l'événement par le service métier.
|
||||||
`processed_at` sera précisée par le service métier de l'étape suivante.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -293,3 +292,5 @@ sudo systemctl restart tableau-de-bord-pro
|
|||||||
Pour aller plus loin, n'hésite pas à consulter les documents suivants à la racine du projet :
|
Pour aller plus loin, n'hésite pas à consulter les documents suivants à la racine du projet :
|
||||||
- [README.md](../README.md) : Présentation générale, instructions d'installation détaillées et script d'import CSV en masse.
|
- [README.md](../README.md) : Présentation générale, instructions d'installation détaillées et script d'import CSV en masse.
|
||||||
- [AGENTS.md](../AGENTS.md) : Guide de développement et consignes pour les agents d'intelligence artificielle travaillant sur ce dépôt.
|
- [AGENTS.md](../AGENTS.md) : Guide de développement et consignes pour les agents d'intelligence artificielle travaillant sur ce dépôt.
|
||||||
|
- [Documentation API REST (Intégration Présence)](api.md) : Spécifications techniques et contractuelles de l'API REST de présence.
|
||||||
|
- [Documentation Home Assistant](home-assistant.md) : Guide de configuration des scripts, secrets et automatisations Home Assistant.
|
||||||
|
|||||||
314
tests/test_api.py
Normal file
314
tests/test_api.py
Normal 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
|
||||||
Reference in New Issue
Block a user