Compare commits
6 Commits
e9977f5a1b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c30bd1c0c5 | |||
| 85e6e502e5 | |||
| 525d38224c | |||
| c5bcbf51bc | |||
|
1a679ea1c7
|
|||
|
b6fa09a709
|
27
AGENTS.md
27
AGENTS.md
@@ -1,6 +1,8 @@
|
|||||||
# CLAUDE.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
Ce fichier fournit des directives et des consignes pour les agents d'intelligence artificielle et assistants de développement (multi-modèles et multi-fournisseurs) travaillant sur ce dépôt.
|
||||||
|
|
||||||
|
Pour une présentation complète de l'architecture, de la configuration et du guide de démarrage, se référer à [docs/onboarding.md](docs/onboarding.md).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -25,14 +27,28 @@ python -m venv .venv
|
|||||||
sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY
|
sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY
|
||||||
sudo systemctl restart tableau-de-bord-pro
|
sudo systemctl restart tableau-de-bord-pro
|
||||||
|
|
||||||
# Git commit (GPG signing désactivé — pinentry inaccessible dans cet env)
|
# Qualité du code (Ruff)
|
||||||
git -c commit.gpgsign=false commit -m "..."
|
.venv/bin/ruff check .
|
||||||
|
.venv/bin/ruff format --check .
|
||||||
|
.venv/bin/ruff format .
|
||||||
|
|
||||||
|
# Découverte des modèles OpenCode (configuration environnementale hors dépôt)
|
||||||
|
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)
|
||||||
|
|
||||||
|
## Git & Conventions de Commit
|
||||||
|
|
||||||
|
- **Politique Git** : Les commits intermédiaires générés par les agents d'IA doivent être non signés en utilisant l'option `git -c commit.gpgsign=false commit -m "..."` (car pinentry est inaccessible dans cet environnement). L'utilisateur effectuera un amend signé (`git commit --amend -S`) du commit final lorsqu'il sera disponible.
|
||||||
|
- **Convention de trailers** : Chaque commit réalisé par un agent d'IA doit inclure le trailer suivant à la fin du message de commit pour identifier le modèle utilisé :
|
||||||
|
```text
|
||||||
|
Co-authored-by: Fournisseur/Modèle <vibecoder@antoineve.me>
|
||||||
|
```
|
||||||
|
*Exemple :* `Co-authored-by: Anthropic/Claude-3.5-Sonnet <vibecoder@antoineve.me>` ou `Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
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.
|
||||||
@@ -62,7 +78,8 @@ Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The D
|
|||||||
|
|
||||||
- **Pas de migration de schéma** : l'app utilise `db.create_all()` uniquement (pas d'Alembic). Tout changement de modèle nécessite de supprimer `instance/worklog.db` en dev, ou une migration manuelle en prod.
|
- **Pas de migration de schéma** : l'app utilise `db.create_all()` uniquement (pas d'Alembic). Tout changement de modèle nécessite de supprimer `instance/worklog.db` en dev, ou une migration manuelle en prod.
|
||||||
- **Barème kilométrique** : les tranches dans `config.toml` sont à mettre à jour manuellement chaque année (section `[bareme_kilometrique.YYYY]`).
|
- **Barème kilométrique** : les tranches dans `config.toml` sont à mettre à jour manuellement chaque année (section `[bareme_kilometrique.YYYY]`).
|
||||||
- **`datetime.utcnow()` deprecated** : les modèles utilisent `datetime.utcnow` (warning sur Python 3.14+). À remplacer par `datetime.now(UTC)` lors d'une prochaine évolution des modèles.
|
- **Métadonnées et horodatages (`created_at` / `updated_at`)** : L'application utilise `datetime.now(UTC)` pour enregistrer ces métadonnées. Bien que les valeurs soient émises en UTC, SQLite et SQLAlchemy restituent par défaut des objets `datetime` naïfs (sans `tzinfo`). Ces champs sont actuellement informatifs et non exploités par la logique métier. En cas de besoin ultérieur d'exploitation de ces métadonnées avec fuseau, les pistes incluent la conservation explicite du fuseau (`DateTime(timezone=True)`) ou la stricte convention documentée « naïf = UTC ».
|
||||||
|
- **Calculs de durée métier et transitions DST** : Le calcul de la durée des plages horaires de travail (`TimeSlot` via `total_minutes()`) est totalement indépendant des métadonnées et repose sur des heures murales (locales). Une plage horaire traversant un changement d'heure saisonnier (passage heure d'été/hiver / DST) soulève un enjeu métier spécifique (gestion des durées d'heures locales) qui nécessiterait, le cas échéant, une représentation dédiée ou une politique métier spécifique.
|
||||||
- **Filtres Jinja2** (définis dans `app/__init__.py`) : `{{ date | date_fr }}` pour les dates en français ; `{{ day_type | day_type_fr }}` pour les libellés de types de jours (WORK→Travail, TT→Télétravail, etc.).
|
- **Filtres Jinja2** (définis dans `app/__init__.py`) : `{{ date | date_fr }}` pour les dates en français ; `{{ day_type | day_type_fr }}` pour les libellés de types de jours (WORK→Travail, TT→Télétravail, etc.).
|
||||||
- **`db.get_engine()` deprecated** en Flask-SQLAlchemy 3.x → utiliser `db.engine`.
|
- **`db.get_engine()` deprecated** en Flask-SQLAlchemy 3.x → utiliser `db.engine`.
|
||||||
- **Migration `_migrate_db`** : vérifier l'existence de la table avant `ALTER TABLE` — SQLite peut avoir un fichier DB sans tables (ex: premier démarrage avec `instance/worklog.db` vide).
|
- **Migration `_migrate_db`** : vérifier l'existence de la table avant `ALTER TABLE` — SQLite peut avoir un fichier DB sans tables (ex: premier démarrage avec `instance/worklog.db` vide).
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Architecture et composants clés :
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import tomllib
|
import tomllib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -36,6 +37,10 @@ def _migrate_db(app):
|
|||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
if db.engine.url.database in (None, ":memory:"):
|
||||||
|
return # Une base mémoire est initialisée par create_all().
|
||||||
|
|
||||||
db_path = os.path.join(app.instance_path, "worklog.db")
|
db_path = os.path.join(app.instance_path, "worklog.db")
|
||||||
if not os.path.exists(db_path):
|
if not os.path.exists(db_path):
|
||||||
return # Nouvelle DB, create_all() s'en charge
|
return # Nouvelle DB, create_all() s'en charge
|
||||||
@@ -120,7 +125,12 @@ def _date_fr(d):
|
|||||||
return f"{jour} {d.day} {mois} {d.year}"
|
return f"{jour} {d.day} {mois} {d.year}"
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_path=None):
|
def create_app(
|
||||||
|
config_path: str | None = None,
|
||||||
|
*,
|
||||||
|
database_uri: str | None = None,
|
||||||
|
engine_options: Mapping[str, object] | None = None,
|
||||||
|
) -> Flask:
|
||||||
"""Factory de création et de configuration de l'application Flask.
|
"""Factory de création et de configuration de l'application Flask.
|
||||||
|
|
||||||
Cette fonction réalise les étapes suivantes :
|
Cette fonction réalise les étapes suivantes :
|
||||||
@@ -136,6 +146,11 @@ def create_app(config_path=None):
|
|||||||
Paramètres:
|
Paramètres:
|
||||||
config_path (str | None): Chemin optionnel vers le fichier de configuration TOML.
|
config_path (str | None): Chemin optionnel vers le fichier de configuration TOML.
|
||||||
Par défaut, cherche `config.toml` à la racine du projet.
|
Par défaut, cherche `config.toml` à la racine du projet.
|
||||||
|
database_uri (str | None): URI SQLAlchemy à utiliser à la place de la base SQLite
|
||||||
|
de l'instance. Cette option est appliquée avant l'initialisation
|
||||||
|
de Flask-SQLAlchemy.
|
||||||
|
engine_options (Mapping[str, object] | None): Options SQLAlchemy appliquées avant
|
||||||
|
l'initialisation de Flask-SQLAlchemy.
|
||||||
|
|
||||||
Retourne:
|
Retourne:
|
||||||
Flask: L'instance de l'application Flask configurée et prête à l'emploi.
|
Flask: L'instance de l'application Flask configurée et prête à l'emploi.
|
||||||
@@ -144,9 +159,11 @@ def create_app(config_path=None):
|
|||||||
|
|
||||||
os.makedirs(app.instance_path, exist_ok=True)
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = (
|
if database_uri is None:
|
||||||
f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
|
database_uri = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
|
||||||
)
|
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
|
||||||
|
if engine_options is not None:
|
||||||
|
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = engine_options
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
||||||
|
|
||||||
@@ -159,6 +176,11 @@ def create_app(config_path=None):
|
|||||||
else:
|
else:
|
||||||
app.config["TOML"] = {}
|
app.config["TOML"] = {}
|
||||||
|
|
||||||
|
from app.config_loader import get_home_assistant_config
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
app.config["HOME_ASSISTANT"] = get_home_assistant_config()
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(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
|
||||||
|
|||||||
@@ -21,8 +21,28 @@ Contrat TOML :
|
|||||||
- Certains types de journées (Télétravail, Maladie, Congé, RTT, Férié) n'impliquent aucun déplacement physique.
|
- Certains types de journées (Télétravail, Maladie, Congé, RTT, Férié) n'impliquent aucun déplacement physique.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
_HOME_ASSISTANT_REQUIRED_KEYS = {
|
||||||
|
"timezone",
|
||||||
|
"default_day_type",
|
||||||
|
"default_journey_profile_id",
|
||||||
|
"default_motor_vehicle_id",
|
||||||
|
}
|
||||||
|
_VALID_DAY_TYPES = {
|
||||||
|
"WORK",
|
||||||
|
"TT",
|
||||||
|
"GARDE",
|
||||||
|
"ASTREINTE",
|
||||||
|
"FORMATION",
|
||||||
|
"RTT",
|
||||||
|
"CONGE",
|
||||||
|
"MALADE",
|
||||||
|
"FERIE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_vehicles():
|
def get_vehicles():
|
||||||
"""Récupère l'ensemble des véhicules configurés dans le fichier TOML.
|
"""Récupère l'ensemble des véhicules configurés dans le fichier TOML.
|
||||||
@@ -58,6 +78,66 @@ def get_journeys():
|
|||||||
return current_app.config.get("TOML", {}).get("journeys", {})
|
return current_app.config.get("TOML", {}).get("journeys", {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_home_assistant_config() -> dict[str, str] | None:
|
||||||
|
"""Retourne la configuration Home Assistant, après validation.
|
||||||
|
|
||||||
|
L'absence de section désactive la future intégration et reste compatible avec
|
||||||
|
les anciens fichiers TOML. Une section présente doit en revanche être
|
||||||
|
complète et cohérente avec les véhicules, trajets et types de journées
|
||||||
|
connus de l'application.
|
||||||
|
"""
|
||||||
|
config = current_app.config.get("TOML", {}).get("home_assistant")
|
||||||
|
if config is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError("Configuration [home_assistant] invalide : la section doit être une table")
|
||||||
|
|
||||||
|
missing = _HOME_ASSISTANT_REQUIRED_KEYS - config.keys()
|
||||||
|
if missing:
|
||||||
|
missing_keys = ", ".join(sorted(missing))
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] incomplète : clé(s) manquante(s) {missing_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
not isinstance(config[key], str) or not config[key] for key in _HOME_ASSISTANT_REQUIRED_KEYS
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Configuration [home_assistant] invalide : toutes les valeurs doivent être des chaînes non vides"
|
||||||
|
)
|
||||||
|
|
||||||
|
timezone = config["timezone"]
|
||||||
|
try:
|
||||||
|
ZoneInfo(timezone)
|
||||||
|
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] invalide : fuseau horaire inconnu {timezone!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
day_type = config["default_day_type"]
|
||||||
|
if day_type not in _VALID_DAY_TYPES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] invalide : type de journée inconnu {day_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
journey_id = config["default_journey_profile_id"]
|
||||||
|
if journey_id not in get_journeys():
|
||||||
|
raise ValueError(f"Configuration [home_assistant] invalide : trajet inconnu {journey_id!r}")
|
||||||
|
|
||||||
|
vehicle_id = config["default_motor_vehicle_id"]
|
||||||
|
vehicle = get_vehicles().get(vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] invalide : véhicule inconnu {vehicle_id!r}"
|
||||||
|
)
|
||||||
|
if vehicle.get("type") != "moteur":
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration [home_assistant] invalide : le véhicule {vehicle_id!r} n'est pas un véhicule moteur"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {key: config[key] for key in _HOME_ASSISTANT_REQUIRED_KEYS}
|
||||||
|
|
||||||
|
|
||||||
def journey_has_motor(journey_profile_id: str | None) -> bool:
|
def journey_has_motor(journey_profile_id: str | None) -> bool:
|
||||||
"""Vérifie si un profil de trajet donné inclut une distance pour véhicule à moteur.
|
"""Vérifie si un profil de trajet donné inclut une distance pour véhicule à moteur.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date, datetime, time
|
from datetime import UTC, date, datetime, time
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy.orm as so
|
import sqlalchemy.orm as so
|
||||||
@@ -27,9 +27,11 @@ class WorkEntry(db.Model):
|
|||||||
motor_vehicle_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
motor_vehicle_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
||||||
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
||||||
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
||||||
created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=datetime.utcnow)
|
created_at: so.Mapped[datetime] = so.mapped_column(
|
||||||
|
sa.DateTime, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
updated_at: so.Mapped[datetime] = so.mapped_column(
|
updated_at: so.Mapped[datetime] = so.mapped_column(
|
||||||
sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
sa.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||||
)
|
)
|
||||||
|
|
||||||
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ distances = { moteur = 14, velo = 8 }
|
|||||||
name = "Vélo seul"
|
name = "Vélo seul"
|
||||||
distances = { velo = 24 }
|
distances = { velo = 24 }
|
||||||
|
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
default_day_type = "WORK"
|
||||||
|
default_journey_profile_id = "moteur_seul"
|
||||||
|
default_motor_vehicle_id = "citadine"
|
||||||
|
|
||||||
# --- Barème kilométrique voitures 2025 (revenus 2024) ---
|
# --- Barème kilométrique voitures 2025 (revenus 2024) ---
|
||||||
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
|
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
|
||||||
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
|
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
|
||||||
|
|||||||
@@ -101,9 +101,20 @@ 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 :
|
||||||
|
```toml
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
default_day_type = "WORK"
|
||||||
|
default_journey_profile_id = "moteur_seul"
|
||||||
|
default_motor_vehicle_id = "citadine"
|
||||||
|
```
|
||||||
|
Cette section est facultative pour préserver le fonctionnement de l'interface Web sur les configurations existantes. Si elle est présente, elle doit être complète et référencer un type de journée, un trajet et un véhicule à moteur existants ; l'application refuse alors de démarrer en cas d'erreur. La configuration est accessible via `get_home_assistant_config()` dans `app/config_loader.py`.
|
||||||
|
|
||||||
*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.
|
||||||
|
|
||||||
### 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 :
|
||||||
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
|
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
|
||||||
@@ -262,8 +273,9 @@ sudo systemctl restart tableau-de-bord-pro
|
|||||||
- En **développement** : Tu peux simplement supprimer le fichier `instance/worklog.db` pour qu'il soit recréé au prochain démarrage (attention, cela supprime tes données de test).
|
- En **développement** : Tu peux simplement supprimer le fichier `instance/worklog.db` pour qu'il soit recréé au prochain démarrage (attention, cela supprime tes données de test).
|
||||||
- En **production** : Tu dois écrire une migration manuelle dans la fonction `_migrate_db` située dans `app/__init__.py`. Cette fonction s'exécute au démarrage de l'application, vérifie l'existence des colonnes via SQLite, et applique les instructions `ALTER TABLE` nécessaires de manière sécurisée.
|
- En **production** : Tu dois écrire une migration manuelle dans la fonction `_migrate_db` située dans `app/__init__.py`. Cette fonction s'exécute au démarrage de l'application, vérifie l'existence des colonnes via SQLite, et applique les instructions `ALTER TABLE` nécessaires de manière sécurisée.
|
||||||
|
|
||||||
### Dépréciations à surveiller
|
### Horodatages de métadonnées et calculs de durée métier
|
||||||
- **`datetime.utcnow()`** : Utilisé dans les modèles de données. Cette méthode est dépréciée depuis Python 3.12. Lors d'une prochaine évolution majeure des modèles, il faudra la remplacer par `datetime.now(UTC)`.
|
- **Métadonnées (`created_at`, `updated_at`)** : L'application utilise `datetime.now(UTC)` pour enregistrer ces métadonnées. Bien que les valeurs soient émises en UTC, SQLite et SQLAlchemy stockent et restituent par défaut des objets `datetime` naïfs (sans `tzinfo`). Ces champs sont actuellement informatifs et non exploités par la logique métier. Si un besoin de lecture ou de manipulation de ces métadonnées avec fuseau émergeait, les pistes incluent l'utilisation de types `DateTime(timezone=True)` ou la stricte convention documentée « naïf = UTC ».
|
||||||
|
- **Calculs de durée métier et transitions DST** : Le calcul de la durée des plages horaires de travail (`TimeSlot` via `total_minutes()`) est totalement indépendant des métadonnées et repose sur des heures murales (locales). Une plage horaire traversant un changement d'heure saisonnier (passage heure d'été/hiver / DST) soulève un enjeu métier spécifique (gestion des durées d'heures locales) qui nécessiterait, le cas échéant, une représentation dédiée ou une politique métier spécifique.
|
||||||
- **`db.get_engine()`** : Déprécié dans Flask-SQLAlchemy 3.x. Utilise toujours `db.engine` à la place.
|
- **`db.get_engine()`** : Déprécié dans Flask-SQLAlchemy 3.x. Utilise toujours `db.engine` à la place.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ def _migrate_db(app):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
engine = db.get_engine()
|
engine = db.engine
|
||||||
if "motor_vehicle_id" not in columns:
|
if "motor_vehicle_id" not in columns:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
conn.execute(sa.text(
|
conn.execute(sa.text(
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ git commit -m "feat: TOML config loader for vehicles, journeys, and tax scales"
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from app import db
|
from app import db
|
||||||
from datetime import date, time, datetime
|
from datetime import date, time, datetime, UTC
|
||||||
from enum import Enum as PyEnum
|
from enum import Enum as PyEnum
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy.orm as so
|
import sqlalchemy.orm as so
|
||||||
@@ -410,9 +410,9 @@ class WorkEntry(db.Model):
|
|||||||
journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
||||||
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
||||||
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
||||||
created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=datetime.utcnow)
|
created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=lambda: datetime.now(UTC))
|
||||||
updated_at: so.Mapped[datetime] = so.mapped_column(
|
updated_at: so.Mapped[datetime] = so.mapped_column(
|
||||||
sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
sa.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||||
)
|
)
|
||||||
|
|
||||||
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
||||||
|
|||||||
266
docs/plans/2026-08-13-api-rest-home-assistant.md
Normal file
266
docs/plans/2026-08-13-api-rest-home-assistant.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# Plan d'implémentation : API REST Home Assistant
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ajouter une API REST privée permettant à Home Assistant d'enregistrer automatiquement
|
||||||
|
les arrivées et les départs dans la zone `lieu de travail`, via `rest_command` et un
|
||||||
|
tracker `person`.
|
||||||
|
|
||||||
|
L'API doit créer automatiquement une journée de type `WORK`, utiliser par défaut le
|
||||||
|
profil de trajet `moteur_seul` et le véhicule `citadine` (Twingo ZE dans le fichier
|
||||||
|
de configuration), tout en laissant la modification complète de la journée dans
|
||||||
|
l'interface Web.
|
||||||
|
|
||||||
|
Le fuseau métier est `Europe/Paris`. Plusieurs plages horaires dans une même journée
|
||||||
|
doivent être supportées, comme dans l'interface existante.
|
||||||
|
|
||||||
|
## Décisions fonctionnelles
|
||||||
|
|
||||||
|
- L'arrivée crée la journée si elle n'existe pas, avec `day_type = "WORK"`.
|
||||||
|
- Le profil de trajet par défaut est `moteur_seul`.
|
||||||
|
- Le véhicule par défaut est `citadine`.
|
||||||
|
- Les journées `FORMATION` et `GARDE` restent compatibles avec le lieu de travail ;
|
||||||
|
l'API ne bloque donc pas ces types lorsqu'une journée existe déjà.
|
||||||
|
- Plusieurs couples arrivée/départ sont autorisés dans la même journée.
|
||||||
|
- Une arrivée ouverte ne doit pas être stockée dans `TimeSlot`, car le modèle actuel
|
||||||
|
exige un `end_time` et `WorkEntry.total_minutes()` suppose une plage complète.
|
||||||
|
- Une table d'événements de présence conserve les arrivées ouvertes et les événements
|
||||||
|
traités ; un `TimeSlot` est créé uniquement lorsqu'un départ complète une arrivée.
|
||||||
|
- Les utilisateurs continuent à corriger les journées et le mode de transport depuis
|
||||||
|
l'interface Web existante.
|
||||||
|
- L'import CSV direct en base n'est pas modifié dans cette livraison.
|
||||||
|
|
||||||
|
## Contrat HTTP cible
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/workplace-presence
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requête
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "arrival",
|
||||||
|
"occurred_at": "2026-08-13T08:23:10+02:00",
|
||||||
|
"idempotency_key": "person.telephone.arrival.20260813T082310+0200"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`event` vaut `arrival` ou `departure`. `occurred_at` est un timestamp ISO 8601
|
||||||
|
avec offset obligatoire. La date et l'heure enregistrées dans le modèle sont
|
||||||
|
calculées dans `Europe/Paris`.
|
||||||
|
|
||||||
|
La clé d'idempotence est obligatoire et doit être limitée en taille. Elle est
|
||||||
|
également acceptée dans `X-Idempotency-Key`; l'implémentation doit refuser une
|
||||||
|
valeur absente ou contradictoire entre l'en-tête et le JSON.
|
||||||
|
|
||||||
|
### Réponses
|
||||||
|
|
||||||
|
- `201 Created` pour une arrivée qui crée un nouvel événement.
|
||||||
|
- `200 OK` pour un départ qui complète une plage ou pour une requête idempotente
|
||||||
|
déjà traitée.
|
||||||
|
- `400` ou `422` pour un JSON ou une valeur métier invalide.
|
||||||
|
- `401` pour un token Bearer absent ou invalide.
|
||||||
|
- `409` pour un départ sans arrivée ouverte ou une transition incohérente.
|
||||||
|
- `415` si le contenu n'est pas `application/json`.
|
||||||
|
- `413` si le corps dépasse la limite configurée.
|
||||||
|
- `429` si la limite de débit est dépassée.
|
||||||
|
|
||||||
|
Toutes les erreurs sont des objets JSON homogènes, sans traceback ni détail interne.
|
||||||
|
Les réponses API indiquent `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
## Étapes d'implémentation
|
||||||
|
|
||||||
|
### 1. Formaliser la configuration
|
||||||
|
|
||||||
|
Ajouter une section dédiée dans `config.toml` :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
default_day_type = "WORK"
|
||||||
|
default_journey_profile_id = "moteur_seul"
|
||||||
|
default_motor_vehicle_id = "citadine"
|
||||||
|
```
|
||||||
|
|
||||||
|
Étendre `app/config_loader.py` avec un accès validé à cette configuration. Vérifier
|
||||||
|
au démarrage que le type de journée, le profil et le véhicule existent et que le
|
||||||
|
véhicule par défaut est bien un véhicule à moteur. Documenter que le token API ne
|
||||||
|
doit pas être placé dans TOML, mais fourni par `WORKLOG_API_TOKEN`.
|
||||||
|
|
||||||
|
### 2. Ajouter la persistance des événements de présence
|
||||||
|
|
||||||
|
Créer un modèle, par exemple `WorkplacePresenceEvent`, contenant au minimum :
|
||||||
|
|
||||||
|
- un identifiant primaire ;
|
||||||
|
- une clé d'idempotence unique ;
|
||||||
|
- le type (`arrival` ou `departure`) ;
|
||||||
|
- le timestamp reçu et le timestamp converti dans `Europe/Paris` ;
|
||||||
|
- la date locale ;
|
||||||
|
- l'identifiant de `WorkEntry` ;
|
||||||
|
- l'identifiant de `TimeSlot` lorsque le départ a complété une plage ;
|
||||||
|
- les dates de création et de traitement.
|
||||||
|
|
||||||
|
Le modèle doit permettre de retrouver une arrivée ouverte pour une date et de
|
||||||
|
conserver la réponse logique d'une requête rejouée. Ajouter les index nécessaires
|
||||||
|
sur la date locale, l'entrée et la clé unique.
|
||||||
|
|
||||||
|
Adapter `_migrate_db` dans `app/__init__.py` pour créer la nouvelle table dans les
|
||||||
|
installations existantes, en vérifiant d'abord la présence de la base et des tables.
|
||||||
|
Ajouter aussi la couverture de la base vide. Respecter la contrainte du projet :
|
||||||
|
il n'y a pas d'Alembic et les changements de schéma sont manuels.
|
||||||
|
|
||||||
|
### 3. Factoriser le service métier
|
||||||
|
|
||||||
|
Créer un service sans dépendance Flask, dans `app/business/`, chargé de :
|
||||||
|
|
||||||
|
- convertir et valider un timestamp ISO 8601 ;
|
||||||
|
- déterminer la date et l'heure locales dans `Europe/Paris` ;
|
||||||
|
- créer ou retrouver une `WorkEntry` avec les valeurs par défaut ;
|
||||||
|
- ouvrir une présence à l'arrivée ;
|
||||||
|
- retrouver l'arrivée ouverte la plus ancienne ou la plus récente selon la règle
|
||||||
|
retenue et la fermer au départ ;
|
||||||
|
- créer un `TimeSlot` complet pour chaque couple ;
|
||||||
|
- accepter plusieurs plages dans la même journée ;
|
||||||
|
- refuser un départ sans arrivée ouverte ;
|
||||||
|
- appliquer l'idempotence dans la même transaction SQLAlchemy.
|
||||||
|
|
||||||
|
La règle de rattachement doit être explicite pour les événements autour de minuit.
|
||||||
|
La date locale de l'arrivée ouvre la plage ; le départ doit être rattaché à cette
|
||||||
|
arrivée ouverte, même si son heure locale est le lendemain. Vérifier que cette
|
||||||
|
plage reste compatible avec le calcul existant du passage de minuit.
|
||||||
|
|
||||||
|
Réutiliser autant que possible les validations communes avec `entries.py`. Ne pas
|
||||||
|
faire dépendre le service des messages Flash, des redirections ou des templates.
|
||||||
|
|
||||||
|
### 4. Implémenter le blueprint API
|
||||||
|
|
||||||
|
Créer `app/routes/api.py` ou `app/routes/api/` et enregistrer le blueprint dans
|
||||||
|
la factory Flask sous `/api/v1`.
|
||||||
|
|
||||||
|
Implémenter :
|
||||||
|
|
||||||
|
- validation stricte du content type et du JSON ;
|
||||||
|
- authentification `Authorization: Bearer ...` ;
|
||||||
|
- comparaison du secret en temps constant ;
|
||||||
|
- contrôle de la clé d'idempotence ;
|
||||||
|
- appel du service métier ;
|
||||||
|
- sérialisation JSON stable ;
|
||||||
|
- traduction des erreurs métier en statuts HTTP ;
|
||||||
|
- gestion générique des erreurs inattendues sans fuite d'informations.
|
||||||
|
|
||||||
|
Limiter l'API à `POST` pour cette première version. Ajouter `405` pour les autres
|
||||||
|
méthodes et ne pas activer CORS, qui n'est pas nécessaire pour Home Assistant.
|
||||||
|
|
||||||
|
Configurer une taille maximale de requête adaptée à ce payload et prévoir un
|
||||||
|
rate limiting simple. Si aucune dépendance n'est souhaitable, une protection
|
||||||
|
minimale par token et fenêtre temporelle peut être implémentée ; sinon sélectionner
|
||||||
|
une dépendance légère et maintenue après vérification des contraintes de production.
|
||||||
|
|
||||||
|
### 5. Préserver le comportement Web
|
||||||
|
|
||||||
|
Vérifier que l'interface existante continue à afficher et modifier les `TimeSlot`
|
||||||
|
complets créés par l'API. Une journée créée par une arrivée doit être éditable
|
||||||
|
même avant le départ, avec zéro plage complète à ce stade.
|
||||||
|
|
||||||
|
Vérifier notamment que l'édition Web peut :
|
||||||
|
|
||||||
|
- changer `WORK` en `FORMATION` ou `GARDE` ;
|
||||||
|
- modifier le profil de trajet ;
|
||||||
|
- modifier le véhicule, notamment abandonner la Twingo par défaut ;
|
||||||
|
- ajouter, supprimer ou corriger plusieurs plages.
|
||||||
|
|
||||||
|
### 6. Documenter l'API séparément
|
||||||
|
|
||||||
|
Créer `docs/api.md` avec :
|
||||||
|
|
||||||
|
- le but et le périmètre de l'API ;
|
||||||
|
- l'URL, le contrat JSON et l'authentification ;
|
||||||
|
- les exemples de réponses `200`, `201`, `401`, `409` et erreurs de validation ;
|
||||||
|
- la sémantique d'idempotence ;
|
||||||
|
- la gestion du fuseau `Europe/Paris` ;
|
||||||
|
- le comportement autour de minuit ;
|
||||||
|
- les règles de déploiement HTTPS/HAProxy ;
|
||||||
|
- les commandes `curl` de test, sans secret en clair dans la documentation.
|
||||||
|
|
||||||
|
Ne jamais écrire de valeur réelle de `WORKLOG_API_TOKEN` dans ce fichier.
|
||||||
|
|
||||||
|
### 7. Documenter Home Assistant
|
||||||
|
|
||||||
|
Créer `docs/home-assistant.md` ou une section dédiée dans `docs/api.md` contenant
|
||||||
|
un exemple complet avec le tracker `person` :
|
||||||
|
|
||||||
|
- stockage du token dans `secrets.yaml` ;
|
||||||
|
- définition de `rest_command` en JSON ;
|
||||||
|
- automatisation d'arrivée lorsque `person.<nom>` passe à `lieu_de_travail` ;
|
||||||
|
- automatisation de départ lorsque l'état quitte cette zone ;
|
||||||
|
- génération d'une clé d'idempotence déterministe ;
|
||||||
|
- contrôle de `response_variable` et traitement des statuts non `200/201` ;
|
||||||
|
- avertissement sur les traces et l'accès administrateur Home Assistant aux secrets.
|
||||||
|
|
||||||
|
L'exemple doit conserver `verify_ssl: true` et utiliser l'en-tête Bearer.
|
||||||
|
|
||||||
|
### 8. Tester et vérifier
|
||||||
|
|
||||||
|
Ajouter des tests de routes et de service couvrant au minimum :
|
||||||
|
|
||||||
|
- arrivée authentifiée créant une journée `WORK` avec `moteur_seul` et `citadine` ;
|
||||||
|
- départ complétant la première plage ;
|
||||||
|
- deuxième arrivée et deuxième départ le même jour ;
|
||||||
|
- journée `FORMATION` ou `GARDE` existante ;
|
||||||
|
- départ sans arrivée ouverte ;
|
||||||
|
- timestamp avec offset et conversion `Europe/Paris` ;
|
||||||
|
- passage de minuit ;
|
||||||
|
- clé rejouée sans duplication ;
|
||||||
|
- clé réutilisée avec un contenu différent ;
|
||||||
|
- token absent ou invalide ;
|
||||||
|
- content type, JSON, champs et longueurs invalides ;
|
||||||
|
- limite de taille et rate limiting ;
|
||||||
|
- absence de secret ou de traceback dans les réponses ;
|
||||||
|
- non-régression des tests HTML existants.
|
||||||
|
|
||||||
|
Exécuter ensuite :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest
|
||||||
|
.venv/bin/ruff check .
|
||||||
|
.venv/bin/ruff format --check .
|
||||||
|
```
|
||||||
|
|
||||||
|
Tester manuellement depuis Home Assistant avec `rest_command` et vérifier la
|
||||||
|
réponse dans les traces d'automatisation, puis tester une répétition du même
|
||||||
|
événement.
|
||||||
|
|
||||||
|
## Hors périmètre et TODO ultérieure
|
||||||
|
|
||||||
|
### Import CSV via l'API
|
||||||
|
|
||||||
|
Ne pas modifier `scripts/import_csv.py` dans cette livraison. Prévoir une TODO
|
||||||
|
ultérieure pour faire passer l'import CSV par l'API plutôt que par des écritures
|
||||||
|
directes en base.
|
||||||
|
|
||||||
|
Cette évolution nécessitera probablement de nouveaux endpoints ou un contrat
|
||||||
|
d'import en lot, par exemple :
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/work-entries
|
||||||
|
POST /api/v1/work-entries/bulk
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle devra définir les règles de transaction, le comportement en cas de doublon,
|
||||||
|
les erreurs par ligne, l'idempotence d'un lot et une authentification adaptée à un
|
||||||
|
client local. Elle devra aussi réutiliser le service métier commun introduit par
|
||||||
|
la présente API.
|
||||||
|
|
||||||
|
## Sources de référence
|
||||||
|
|
||||||
|
- Home Assistant, `rest_command` : https://www.home-assistant.io/integrations/rest_command/
|
||||||
|
- Home Assistant, secrets : https://www.home-assistant.io/docs/configuration/secrets/
|
||||||
|
- OWASP REST Security Cheat Sheet : https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
|
||||||
|
- OWASP API Security Top 10 : https://owasp.org/API-Security/editions/2023/en/0x11-t10/
|
||||||
|
- Flask, Web Security Considerations : https://flask.palletsprojects.com/en/stable/web-security/
|
||||||
|
|
||||||
|
La recherche a été réalisée avec des moyens Web alternatifs ; FireCrawl local
|
||||||
|
n'était pas disponible au moment de la préparation du plan.
|
||||||
@@ -2,6 +2,7 @@ import pytest
|
|||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from app import db as _db
|
from app import db as _db
|
||||||
|
from tests.in_memory_db import IN_MEMORY_DATABASE_URI, in_memory_engine_options
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -48,6 +49,12 @@ distances = { moteur = 14, velo = 8 }
|
|||||||
name = "Vélo seul"
|
name = "Vélo seul"
|
||||||
distances = { velo = 24 }
|
distances = { velo = 24 }
|
||||||
|
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
default_day_type = "WORK"
|
||||||
|
default_journey_profile_id = "moteur_seul"
|
||||||
|
default_motor_vehicle_id = "citadine"
|
||||||
|
|
||||||
[[bareme_kilometrique.2025.cv_5.tranches]]
|
[[bareme_kilometrique.2025.cv_5.tranches]]
|
||||||
km_max = 3000
|
km_max = 3000
|
||||||
taux = 0.548
|
taux = 0.548
|
||||||
@@ -66,9 +73,12 @@ forfait = 0
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
application = create_app(config_path=str(config_path))
|
application = create_app(
|
||||||
|
config_path=str(config_path),
|
||||||
|
database_uri=IN_MEMORY_DATABASE_URI,
|
||||||
|
engine_options=in_memory_engine_options(),
|
||||||
|
)
|
||||||
application.config["TESTING"] = True
|
application.config["TESTING"] = True
|
||||||
application.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
|
||||||
|
|
||||||
with application.app_context():
|
with application.app_context():
|
||||||
_db.create_all()
|
_db.create_all()
|
||||||
|
|||||||
20
tests/in_memory_db.py
Normal file
20
tests/in_memory_db.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Configuration SQLite mémoire partagée entre fixtures et helpers de tests.
|
||||||
|
|
||||||
|
Ce module est volontairement indépendant de ``conftest`` afin d'être
|
||||||
|
importable de manière portable, y compris sous ``pytest --import-mode=importlib``
|
||||||
|
(où ``conftest`` n'est pas importable comme module ordinaire). Il centralise la
|
||||||
|
configuration mémoire partagée entre la fixture ``app`` et les helpers de tests
|
||||||
|
qui appellent directement ``create_app(...)``, évitant qu'une factory de test
|
||||||
|
initialise accidentellement ``instance/worklog.db``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
IN_MEMORY_DATABASE_URI = "sqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
def in_memory_engine_options() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"poolclass": StaticPool,
|
||||||
|
"connect_args": {"check_same_thread": False},
|
||||||
|
}
|
||||||
11
tests/test_app_factory.py
Normal file
11
tests/test_app_factory.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_fixture_uses_one_in_memory_database_connection(app):
|
||||||
|
with app.app_context():
|
||||||
|
assert str(db.engine.url) == "sqlite:///:memory:"
|
||||||
|
assert isinstance(db.engine.pool, StaticPool)
|
||||||
|
assert sa.inspect(db.engine).has_table("work_entries")
|
||||||
@@ -1,3 +1,39 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from tests.in_memory_db import IN_MEMORY_DATABASE_URI, in_memory_engine_options
|
||||||
|
|
||||||
|
_MINIMAL_CONFIG = """
|
||||||
|
[vehicles.citadine]
|
||||||
|
name = "Citadine"
|
||||||
|
type = "moteur"
|
||||||
|
|
||||||
|
[vehicles.velo]
|
||||||
|
name = "Vélo"
|
||||||
|
type = "velo"
|
||||||
|
|
||||||
|
[journeys.moteur_seul]
|
||||||
|
name = "Moteur seul"
|
||||||
|
distances = {{ moteur = 1 }}
|
||||||
|
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "{timezone}"
|
||||||
|
default_day_type = "{day_type}"
|
||||||
|
default_journey_profile_id = "{journey_id}"
|
||||||
|
default_motor_vehicle_id = "{vehicle_id}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _create_app_with_home_assistant_config(tmp_path, **values):
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text(_MINIMAL_CONFIG.format(**values), encoding="utf-8")
|
||||||
|
return create_app(
|
||||||
|
config_path=str(config_path),
|
||||||
|
database_uri=IN_MEMORY_DATABASE_URI,
|
||||||
|
engine_options=in_memory_engine_options(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_vehicles_returns_configured_vehicles(app):
|
def test_get_vehicles_returns_configured_vehicles(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import get_vehicles
|
from app.config_loader import get_vehicles
|
||||||
@@ -59,3 +95,82 @@ def test_day_types_without_journey(app):
|
|||||||
types = day_types_without_journey()
|
types = day_types_without_journey()
|
||||||
assert "TT" in types
|
assert "TT" in types
|
||||||
assert "WORK" not in types
|
assert "WORK" not in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_home_assistant_config_returns_validated_defaults(app):
|
||||||
|
with app.app_context():
|
||||||
|
from app.config_loader import get_home_assistant_config
|
||||||
|
|
||||||
|
assert get_home_assistant_config() == {
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"default_day_type": "WORK",
|
||||||
|
"default_journey_profile_id": "moteur_seul",
|
||||||
|
"default_motor_vehicle_id": "citadine",
|
||||||
|
}
|
||||||
|
assert app.config["HOME_ASSISTANT"]["default_motor_vehicle_id"] == "citadine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_assistant_section_absent_is_allowed(app):
|
||||||
|
with app.app_context():
|
||||||
|
from app.config_loader import get_home_assistant_config
|
||||||
|
|
||||||
|
app.config["TOML"].pop("home_assistant")
|
||||||
|
assert get_home_assistant_config() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value", "message"),
|
||||||
|
[
|
||||||
|
("timezone", "Mars/NoSuchPlace", "fuseau horaire"),
|
||||||
|
("day_type", "UNKNOWN", "type de journée"),
|
||||||
|
("journey_id", "unknown_journey", "trajet inconnu"),
|
||||||
|
("vehicle_id", "unknown_vehicle", "véhicule inconnu"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_home_assistant_config_prevents_startup(tmp_path, field, value, message):
|
||||||
|
values = {
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"day_type": "WORK",
|
||||||
|
"journey_id": "moteur_seul",
|
||||||
|
"vehicle_id": "citadine",
|
||||||
|
}
|
||||||
|
values[field] = value
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
_create_app_with_home_assistant_config(tmp_path, **values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_assistant_default_vehicle_must_be_motor_vehicle(tmp_path):
|
||||||
|
values = {
|
||||||
|
"timezone": "Europe/Paris",
|
||||||
|
"day_type": "WORK",
|
||||||
|
"journey_id": "moteur_seul",
|
||||||
|
"vehicle_id": "velo",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="véhicule moteur"):
|
||||||
|
_create_app_with_home_assistant_config(tmp_path, **values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_home_assistant_config_prevents_startup(tmp_path):
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text(
|
||||||
|
"""
|
||||||
|
[vehicles.citadine]
|
||||||
|
type = "moteur"
|
||||||
|
|
||||||
|
[journeys.moteur_seul]
|
||||||
|
distances = { moteur = 1 }
|
||||||
|
|
||||||
|
[home_assistant]
|
||||||
|
timezone = "Europe/Paris"
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="clé.*manquante"):
|
||||||
|
create_app(
|
||||||
|
config_path=str(config_path),
|
||||||
|
database_uri=IN_MEMORY_DATABASE_URI,
|
||||||
|
engine_options=in_memory_engine_options(),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user