Compare commits
6 Commits
c30bd1c0c5
...
fc3250f431
| Author | SHA1 | Date | |
|---|---|---|---|
| fc3250f431 | |||
| bca00a06bf | |||
| 704e73b936 | |||
| 874328006a | |||
| a27f282b69 | |||
| e9f73c7562 |
@@ -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
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
|
||||
227
app/business/presence_service.py
Normal file
227
app/business/presence_service.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Enregistrement métier des événements de présence Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, time
|
||||
from typing import Any, Callable, Literal, Mapping
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
|
||||
|
||||
EventType = Literal["arrival", "departure"]
|
||||
SlotState = Literal["open", "closed"]
|
||||
|
||||
|
||||
class PresenceServiceError(ValueError):
|
||||
"""Erreur métier prévisible lors de l'enregistrement d'une présence."""
|
||||
|
||||
|
||||
class InvalidPresenceEventError(PresenceServiceError):
|
||||
"""Les données de l'événement ne respectent pas le contrat métier."""
|
||||
|
||||
|
||||
class IdempotencyConflictError(PresenceServiceError):
|
||||
"""La clé est déjà utilisée par un événement différent."""
|
||||
|
||||
|
||||
class ArrivalAlreadyOpenError(PresenceServiceError):
|
||||
"""Une arrivée est déjà ouverte, quelle que soit sa journée."""
|
||||
|
||||
|
||||
class DepartureWithoutArrivalError(PresenceServiceError):
|
||||
"""Aucune arrivée ouverte ne peut être fermée."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresenceResult:
|
||||
"""Résultat sérialisable par la future route API."""
|
||||
|
||||
event_id: int
|
||||
entry_id: int
|
||||
time_slot_id: int | None
|
||||
replayed: bool
|
||||
slot_state: SlotState
|
||||
|
||||
@property
|
||||
def event_created(self) -> bool:
|
||||
return not self.replayed
|
||||
|
||||
|
||||
def _parse_occurred_at(value: str, timezone: ZoneInfo) -> tuple[datetime, datetime, date, time]:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise InvalidPresenceEventError("occurred_at doit être un ISO 8601 valide") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise InvalidPresenceEventError("occurred_at doit comporter un offset explicite")
|
||||
local = parsed.astimezone(timezone)
|
||||
wall_time = local.replace(tzinfo=None)
|
||||
return wall_time, local, local.date(), local.time()
|
||||
|
||||
|
||||
def _received_at(value: datetime | None, clock: Callable[[], datetime] | None) -> datetime:
|
||||
received = value if value is not None else (clock() if clock else datetime.now(UTC))
|
||||
if received.tzinfo is None or received.utcoffset() is None:
|
||||
return received
|
||||
return received.astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _validate_config(config: Mapping[str, Any]) -> tuple[ZoneInfo, str, str, str]:
|
||||
try:
|
||||
timezone_name = config["timezone"]
|
||||
defaults = (
|
||||
config["default_day_type"],
|
||||
config["default_journey_profile_id"],
|
||||
config["default_motor_vehicle_id"],
|
||||
)
|
||||
timezone = ZoneInfo(timezone_name)
|
||||
except (KeyError, TypeError, ZoneInfoNotFoundError, ValueError) as exc:
|
||||
raise InvalidPresenceEventError("Configuration Home Assistant invalide") from exc
|
||||
if not isinstance(timezone_name, str) or not all(isinstance(item, str) for item in defaults):
|
||||
raise InvalidPresenceEventError("Configuration Home Assistant invalide")
|
||||
return timezone, defaults[0], defaults[1], defaults[2]
|
||||
|
||||
|
||||
def _result(event: WorkplacePresenceEvent, replayed: bool) -> PresenceResult:
|
||||
return PresenceResult(
|
||||
event_id=event.id,
|
||||
entry_id=event.entry_id,
|
||||
time_slot_id=event.time_slot_id,
|
||||
replayed=replayed,
|
||||
slot_state="closed" if event.time_slot_id is not None else "open",
|
||||
)
|
||||
|
||||
|
||||
def record_presence_event(
|
||||
session: Session,
|
||||
config: Mapping[str, Any],
|
||||
event_type: str,
|
||||
occurred_at: str,
|
||||
idempotency_key: str,
|
||||
*,
|
||||
received_at: datetime | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> PresenceResult:
|
||||
"""Enregistre une arrivée ou un départ sans valider la transaction SQLAlchemy."""
|
||||
if not hasattr(session, "in_transaction"):
|
||||
session = session()
|
||||
if event_type not in ("arrival", "departure"):
|
||||
raise InvalidPresenceEventError("event_type doit valoir arrival ou departure")
|
||||
if not isinstance(idempotency_key, str) or not idempotency_key or len(idempotency_key) > 255:
|
||||
raise InvalidPresenceEventError(
|
||||
"idempotency_key doit être non vide et limitée à 255 caractères"
|
||||
)
|
||||
|
||||
timezone, day_type, journey_id, vehicle_id = _validate_config(config)
|
||||
occurred_wall, occurred_local, local_date, wall_time = _parse_occurred_at(occurred_at, timezone)
|
||||
received_wall = _received_at(received_at, clock)
|
||||
|
||||
# Le SELECT démarre explicitement la transaction racine. Aucun contexte ne
|
||||
# valide cette transaction : la route appelante garde la décision finale.
|
||||
session.execute(select(1))
|
||||
try:
|
||||
if session:
|
||||
existing = session.scalar(
|
||||
select(WorkplacePresenceEvent).where(
|
||||
WorkplacePresenceEvent.idempotency_key == idempotency_key
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.event_type != event_type or existing.occurred_at != occurred_wall:
|
||||
raise IdempotencyConflictError("La clé d'idempotence est déjà utilisée")
|
||||
return _result(existing, replayed=True)
|
||||
|
||||
if event_type == "arrival":
|
||||
# Une seule arrivée peut être ouverte dans toute l'application :
|
||||
# le prochain départ doit toujours avoir un rattachement unique.
|
||||
open_arrival = session.scalar(
|
||||
select(WorkplacePresenceEvent).where(
|
||||
WorkplacePresenceEvent.event_type == "arrival",
|
||||
WorkplacePresenceEvent.time_slot_id.is_(None),
|
||||
)
|
||||
)
|
||||
if open_arrival is not None:
|
||||
raise ArrivalAlreadyOpenError("Une arrivée est déjà ouverte")
|
||||
entry = session.scalar(select(WorkEntry).where(WorkEntry.date == local_date))
|
||||
if entry is None:
|
||||
entry = WorkEntry(
|
||||
date=local_date,
|
||||
day_type=day_type,
|
||||
journey_profile_id=journey_id,
|
||||
motor_vehicle_id=vehicle_id,
|
||||
)
|
||||
session.add(entry)
|
||||
session.flush()
|
||||
event = WorkplacePresenceEvent(
|
||||
idempotency_key=idempotency_key,
|
||||
event_type="arrival",
|
||||
received_at=received_wall,
|
||||
occurred_at=occurred_wall,
|
||||
local_date=local_date,
|
||||
entry=entry,
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
return _result(event, replayed=False)
|
||||
|
||||
open_arrivals = session.scalars(
|
||||
select(WorkplacePresenceEvent)
|
||||
.where(
|
||||
WorkplacePresenceEvent.event_type == "arrival",
|
||||
WorkplacePresenceEvent.time_slot_id.is_(None),
|
||||
)
|
||||
.order_by(WorkplacePresenceEvent.occurred_at, WorkplacePresenceEvent.id)
|
||||
).all()
|
||||
if not open_arrivals:
|
||||
raise DepartureWithoutArrivalError("Aucune arrivée ouverte")
|
||||
if len(open_arrivals) > 1:
|
||||
raise ArrivalAlreadyOpenError("Plusieurs arrivées sont ouvertes")
|
||||
arrival = open_arrivals[0]
|
||||
arrival_local = arrival.occurred_at.replace(tzinfo=timezone)
|
||||
if occurred_local <= arrival_local:
|
||||
raise InvalidPresenceEventError(
|
||||
"L'instant du départ doit être postérieur à celui de l'arrivée"
|
||||
)
|
||||
slot = TimeSlot(
|
||||
entry_id=arrival.entry_id, start_time=arrival.occurred_at.time(), end_time=wall_time
|
||||
)
|
||||
session.add(slot)
|
||||
departure = WorkplacePresenceEvent(
|
||||
idempotency_key=idempotency_key,
|
||||
event_type="departure",
|
||||
received_at=received_wall,
|
||||
occurred_at=occurred_wall,
|
||||
local_date=local_date,
|
||||
entry_id=arrival.entry_id,
|
||||
time_slot=slot,
|
||||
processed_at=received_wall,
|
||||
)
|
||||
session.add(departure)
|
||||
session.flush()
|
||||
arrival.time_slot = slot
|
||||
arrival.processed_at = received_wall
|
||||
session.flush()
|
||||
return _result(departure, replayed=False)
|
||||
except IntegrityError as exc:
|
||||
# Une autre requête peut avoir gagné la clé entre le contrôle et le flush.
|
||||
session.rollback()
|
||||
existing = session.scalar(
|
||||
select(WorkplacePresenceEvent).where(
|
||||
WorkplacePresenceEvent.idempotency_key == idempotency_key
|
||||
)
|
||||
)
|
||||
if (
|
||||
existing is not None
|
||||
and existing.event_type == event_type
|
||||
and existing.occurred_at == occurred_wall
|
||||
):
|
||||
return _result(existing, replayed=True)
|
||||
raise IdempotencyConflictError("Conflit d'unicité lors de l'enregistrement") from exc
|
||||
|
||||
|
||||
record_home_assistant_event = record_presence_event
|
||||
@@ -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
203
docs/api.md
Normal 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
143
docs/home-assistant.md
Normal 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.
|
||||
@@ -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
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
|
||||
@@ -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
111
tests/test_models.py
Normal 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")
|
||||
163
tests/test_presence_service.py
Normal file
163
tests/test_presence_service.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
from app.business.presence_service import (
|
||||
ArrivalAlreadyOpenError,
|
||||
DepartureWithoutArrivalError,
|
||||
IdempotencyConflictError,
|
||||
InvalidPresenceEventError,
|
||||
record_presence_event,
|
||||
)
|
||||
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
|
||||
|
||||
CONFIG = {
|
||||
"timezone": "Europe/Paris",
|
||||
"default_day_type": "WORK",
|
||||
"default_journey_profile_id": "moteur_seul",
|
||||
"default_motor_vehicle_id": "citadine",
|
||||
}
|
||||
RECEIVED = datetime(2026, 8, 13, 7, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def call(session, event_type, occurred_at, key, **kwargs):
|
||||
return record_presence_event(session, CONFIG, event_type, occurred_at, key, **kwargs)
|
||||
|
||||
|
||||
def test_arrival_creates_entry_with_defaults_without_commit(app):
|
||||
with app.app_context():
|
||||
result = call(
|
||||
db.session, "arrival", "2026-08-13T08:23:10+02:00", "a-1", received_at=RECEIVED
|
||||
)
|
||||
|
||||
entry = db.session.get(WorkEntry, result.entry_id)
|
||||
assert result.replayed is False
|
||||
assert result.slot_state == "open"
|
||||
assert (entry.day_type, entry.journey_profile_id, entry.motor_vehicle_id) == (
|
||||
"WORK",
|
||||
"moteur_seul",
|
||||
"citadine",
|
||||
)
|
||||
assert db.session.query(WorkplacePresenceEvent).count() == 1
|
||||
db.session.rollback()
|
||||
assert db.session.query(WorkplacePresenceEvent).count() == 0
|
||||
|
||||
|
||||
def test_departure_completes_both_events(app):
|
||||
with app.app_context():
|
||||
arrival = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||
departure = call(db.session, "departure", "2026-08-13T17:00:00+02:00", "d-1")
|
||||
events = db.session.scalars(
|
||||
db.select(WorkplacePresenceEvent).order_by(WorkplacePresenceEvent.id)
|
||||
).all()
|
||||
|
||||
assert departure.time_slot_id is not None
|
||||
assert departure.entry_id == arrival.entry_id
|
||||
assert all(event.time_slot_id == departure.time_slot_id for event in events)
|
||||
assert all(event.processed_at is not None for event in events)
|
||||
assert db.session.get(TimeSlot, departure.time_slot_id).start_time.hour == 8
|
||||
|
||||
|
||||
def test_second_pair_same_day_is_supported(app):
|
||||
with app.app_context():
|
||||
first = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||
call(db.session, "departure", "2026-08-13T12:00:00+02:00", "d-1")
|
||||
call(db.session, "arrival", "2026-08-13T13:00:00+02:00", "a-2")
|
||||
second = call(db.session, "departure", "2026-08-13T17:00:00+02:00", "d-2")
|
||||
|
||||
assert second.entry_id == first.entry_id
|
||||
assert db.session.query(TimeSlot).count() == 2
|
||||
|
||||
|
||||
def test_arrival_is_global_across_days_and_does_not_create_entry(app):
|
||||
with app.app_context():
|
||||
call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||
|
||||
with pytest.raises(ArrivalAlreadyOpenError):
|
||||
call(db.session, "arrival", "2026-08-14T08:00:00+02:00", "a-2")
|
||||
|
||||
assert db.session.query(WorkEntry).count() == 1
|
||||
assert db.session.query(WorkplacePresenceEvent).count() == 1
|
||||
assert db.session.query(TimeSlot).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"departure_at",
|
||||
["2026-08-13T08:00:00+02:00", "2026-08-13T07:59:59+02:00"],
|
||||
ids=["equal", "before"],
|
||||
)
|
||||
def test_invalid_departure_does_not_create_slot_or_event(app, departure_at):
|
||||
with app.app_context():
|
||||
call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||
|
||||
with pytest.raises(InvalidPresenceEventError):
|
||||
call(db.session, "departure", departure_at, "d-1")
|
||||
|
||||
assert db.session.query(TimeSlot).count() == 0
|
||||
assert db.session.query(WorkplacePresenceEvent).count() == 1
|
||||
assert (
|
||||
db.session.query(WorkplacePresenceEvent).filter_by(event_type="departure").count() == 0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day_type", ["FORMATION", "GARDE"])
|
||||
def test_existing_special_day_is_not_modified(app, day_type):
|
||||
with app.app_context():
|
||||
entry = WorkEntry(
|
||||
date=date(2026, 8, 13),
|
||||
day_type=day_type,
|
||||
journey_profile_id="other-journey",
|
||||
motor_vehicle_id="other-car",
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
result = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||
db.session.refresh(entry)
|
||||
|
||||
assert result.entry_id == entry.id
|
||||
assert (entry.day_type, entry.journey_profile_id, entry.motor_vehicle_id) == (
|
||||
day_type,
|
||||
"other-journey",
|
||||
"other-car",
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_transitions_and_payloads(app):
|
||||
with app.app_context():
|
||||
with pytest.raises(ArrivalAlreadyOpenError):
|
||||
call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "a-1")
|
||||
call(db.session, "arrival", "2026-08-13T09:00:00+02:00", "a-2")
|
||||
db.session.rollback()
|
||||
with pytest.raises(DepartureWithoutArrivalError):
|
||||
call(db.session, "departure", "2026-08-13T09:00:00+02:00", "d-1")
|
||||
for event_type, occurred_at, key in (
|
||||
("other", "2026-08-13T09:00:00+02:00", "x"),
|
||||
("arrival", "2026-08-13T09:00:00", "x"),
|
||||
("arrival", "not-a-date", "x"),
|
||||
("arrival", "2026-08-13T09:00:00+02:00", ""),
|
||||
):
|
||||
with pytest.raises(InvalidPresenceEventError):
|
||||
call(db.session, event_type, occurred_at, key)
|
||||
|
||||
|
||||
def test_departure_after_midnight_is_accepted_as_a_later_instant(app):
|
||||
with app.app_context():
|
||||
arrival = call(db.session, "arrival", "2026-08-13T23:30:00+00:00", "a-1")
|
||||
departure = call(db.session, "departure", "2026-08-14T00:30:00+00:00", "d-1")
|
||||
event = db.session.get(WorkplacePresenceEvent, arrival.event_id)
|
||||
slot = db.session.get(TimeSlot, departure.time_slot_id)
|
||||
|
||||
assert event.occurred_at == datetime(2026, 8, 14, 1, 30)
|
||||
assert event.local_date == date(2026, 8, 14)
|
||||
assert (slot.start_time.hour, slot.end_time.hour) == (1, 2)
|
||||
|
||||
|
||||
def test_replay_and_key_conflict(app):
|
||||
with app.app_context():
|
||||
first = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "same")
|
||||
replay = call(db.session, "arrival", "2026-08-13T08:00:00+02:00", "same")
|
||||
assert replay.replayed is True
|
||||
assert replay.event_id == first.event_id
|
||||
with pytest.raises(IdempotencyConflictError):
|
||||
call(db.session, "arrival", "2026-08-13T08:01:00+02:00", "same")
|
||||
Reference in New Issue
Block a user