18 Commits

Author SHA1 Message Date
e9f73c7562 feat: persister les événements de présence et activer les clés étrangères SQLite
Ajoute le modèle WorkplacePresenceEvent pour stocker les événements de
présence reçus de Home Assistant, avec clé d'idempotence unique, lien vers
une journée et, facultativement, une plage horaire. Active les contraintes
de clés étrangères sur chaque connexion SQLite et documente le schéma dans
l'onboarding. Couvre le tout par des tests de modèle et de factory.

Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-13 17:36:26 +02:00
c30bd1c0c5 fix(tests): isolate SQLite test databases
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
2026-08-13 17:25:48 +02:00
85e6e502e5 feat(config): ajouter la configuration Home Assistant (étape 1)
Ajoute la section [home_assistant] au TOML et sa validation au chargement
via get_home_assistant_config(). La section est facultative pour préserver
la compatibilité avec les configurations existantes, mais 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, sous peine de refuser le
démarrage de l'application. La configuration validée est exposée dans
app.config['HOME_ASSISTANT'].

Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-13 16:13:18 +02:00
525d38224c docs: planifier l'API REST Home Assistant
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-13 15:57:32 +02:00
c5bcbf51bc docs: utiliser db.engine au lieu de db.get_engine() déprécié
Co-authored-by: OpenAI/GPT-5.6-terra <vibecoder@antoineve.me>
2026-08-13 15:28:43 +02:00
1a679ea1c7 refactor: migrer datetime.utcnow() vers datetime.now(UTC)
Co-authored-by: OpenAI/GPT-5.6-terra <vibecoder@antoineve.me>
2026-08-13 15:23:09 +02:00
b6fa09a709 doc: improve contributor guidance
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 12:25:08 +02:00
e9977f5a1b doc: clarify Python version requirement
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 12:02:15 +02:00
fd128dd09b chore: resolve final review findings
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Pro <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:48:38 +02:00
21fd877d7a doc: add developer onboarding guide
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:38:04 +02:00
bd5bc496eb doc: document configuration and application routes
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:05:10 +02:00
2d85fffd8d doc: document models and business rules
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Pro <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 10:58:49 +02:00
c7a0a77d1f chore: fix python style violations
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: OpenAI/GPT-5.6-Luna <vibecoder@antoineve.me>
Co-authored-by: MiniMax/MiniMax-M3 <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 10:14:21 +02:00
82beb6241f chore: configure python quality tooling
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: OpenAI/GPT-5.6-Luna <vibecoder@antoineve.me>
2026-08-13 10:08:13 +02:00
bfa48ee8a8 Add bulk CSV import script for work entries
- Add scripts/import_csv.py for direct SQLite database import
- Handle date conflicts with warnings (existing data preserved)
- Support multiple time slots per entry (semicolon-separated)
- Validate day_type, journey_profile_id, and motor_vehicle_id
- Update README.md with usage instructions
2026-05-13 18:45:27 +02:00
0956b22986 Add 2026 kilometer rate scale (unchanged from 2025) 2026-05-13 18:21:13 +02:00
8fe13615d0 CLAUDE.md -> AGENTS.md 2026-05-13 18:12:54 +02:00
7e2f158c09 Merge pull request 'feat: statistiques mensuelles dans la page rapports' (#2) from monthly-stats-reports into master
Reviewed-on: #2
2026-03-13 14:42:37 +01:00
30 changed files with 1993 additions and 155 deletions

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ __pycache__/
instance/
.env
*.db
.ruff_cache/
.pytest_cache/

View File

@@ -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
@@ -25,14 +27,28 @@ python -m venv .venv
sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY
sudo systemctl restart tableau-de-bord-pro
# Git commit (GPG signing désactivé — pinentry inaccessible dans cet env)
git -c commit.gpgsign=false commit -m "..."
# Qualité du code (Ruff)
.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
- `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
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.
- **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.).
- **`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).

View File

@@ -74,6 +74,40 @@ Toute la configuration métier se trouve dans `config.toml` :
.venv/bin/python -m pytest
```
### Qualité Python
Ruff est l'unique outil de lint, de tri des imports et de formatage Python :
```bash
.venv/bin/ruff check .
.venv/bin/ruff format --check .
```
La configuration se trouve dans `pyproject.toml` (Python 3.11+, lignes de 100
caractères). Le formatage peut être appliqué avec `.venv/bin/ruff format .` lors
de l'étape de correction dédiée.
### Import bulk depuis CSV
Un script est disponible pour importer des entrées en masse depuis un fichier CSV :
```bash
# Format du CSV : date,day_type,journey_profile_id,motor_vehicle_id,start_time,end_time,comment
# Exemple :
# 2025-06-02,WORK,moteur_seul,familiale,09:00;14:00,17:45;12:00,Travail normal
# 2025-06-03,TT,,,09:00,17:45,Télétravail
.venv/bin/python scripts/import_csv.py mon_fichier.csv
# Avec une config personnalisée
.venv/bin/python scripts/import_csv.py mon_fichier.csv --config /chemin/vers/config.toml
```
**Comportement :**
- En cas de conflit sur une date, les données existantes sont conservées et un avertissement est affiché
- Les plages horaires multiples peuvent être séparées par des points-virgules (`;`)
- Types de jour valides : WORK, TT, GARDE, ASTREINTE, FORMATION, RTT, CONGE, MALADE, FERIE
## Licence
[MIT](LICENSE.md) — Copyright (c) 2026 Antoine Van-Elstraete

View File

@@ -1,15 +1,55 @@
"""Package principal de l'application Flask.
Ce package initialise l'application Flask en utilisant le "Factory Pattern" (via la fonction `create_app`).
Il configure également la base de données SQLite (via Flask-SQLAlchemy), charge la configuration TOML,
enregistre les blueprints de routes, applique les migrations de schéma manuelles, et définit les filtres Jinja2 personnalisés.
Architecture et composants clés :
1. Factory Pattern : `create_app` permet d'instancier l'application de manière isolée, facilitant les tests.
2. Base de données : SQLite stockée dans `instance/worklog.db`.
3. Migration de schéma : Gérée manuellement par `_migrate_db` sans utiliser Alembic.
4. Filtres Jinja2 :
- `date_fr` : Formate une date Python en chaîne lisible en français (ex: "mercredi 11 mars 2026").
- `day_type_fr` : Traduit les codes internes des types de jours (ex: "WORK" -> "Travail").
"""
import os
import tomllib
from collections.abc import Mapping
import sqlalchemy as sa
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import tomllib
import os
import sqlalchemy as sa
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 (pas d'Alembic)."""
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic).
Cette fonction vérifie l'état actuel de la base de données SQLite avant d'exécuter
des instructions DDL (comme `ALTER TABLE`). Elle permet d'éviter les erreurs si la table
`work_entries` n'existe pas encore (auquel cas `db.create_all()` s'en charge) ou si la
colonne `motor_vehicle_id` a déjà été ajoutée lors d'un démarrage précédent.
Paramètres:
app (Flask): L'instance de l'application Flask en cours d'initialisation.
"""
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")
if not os.path.exists(db_path):
return # Nouvelle DB, create_all() s'en charge
@@ -25,48 +65,114 @@ def _migrate_db(app):
engine = db.engine
if "motor_vehicle_id" not in columns:
with engine.connect() as conn:
conn.execute(sa.text(
"ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)"
))
conn.execute(
sa.text("ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)")
)
conn.commit()
_JOURS_FR = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
_MOIS_FR = ["", "janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"]
_MOIS_FR = [
"",
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
]
_DAY_TYPE_LABELS = {
"WORK": "Travail",
"TT": "Télétravail",
"GARDE": "Garde",
"ASTREINTE": "Astreinte",
"FORMATION": "Formation",
"RTT": "RTT",
"CONGE": "Congé",
"MALADE": "Maladie",
"FERIE": "Férié",
"WORK": "Travail",
"TT": "Télétravail",
"GARDE": "Garde",
"ASTREINTE": "Astreinte",
"FORMATION": "Formation",
"RTT": "RTT",
"CONGE": "Congé",
"MALADE": "Maladie",
"FERIE": "Férié",
}
def _day_type_fr(code):
"""Filtre Jinja2 pour traduire un code de type de jour en libellé français.
Exemple:
`{{ 'WORK' | day_type_fr }}` -> "Travail"
Paramètres:
code (str): Le code interne du type de jour (ex: "WORK", "TT", "GARDE").
Retourne:
str: Le libellé en français correspondant, ou le code d'origine si aucune traduction n'est définie.
"""
return _DAY_TYPE_LABELS.get(code, code)
def _date_fr(d):
"""Formate une date en français : 'mercredi 11 mars 2026'."""
from datetime import date as date_type
"""Filtre Jinja2 pour formater une date en français lisible.
Exemple:
`{{ entry.date | date_fr }}` -> "mercredi 11 mars 2026"
Paramètres:
d (datetime.date): L'objet date à formater.
Retourne:
str: La date formatée en français (jour de la semaine, jour du mois, mois en toutes lettres, année).
"""
jour = _JOURS_FR[d.weekday()]
mois = _MOIS_FR[d.month]
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.
Cette fonction réalise les étapes suivantes :
1. Instancie l'application Flask avec le support des configurations relatives à l'instance.
2. Crée le dossier d'instance s'il n'existe pas.
3. Configure l'URI de la base de données SQLite (`instance/worklog.db`).
4. Charge la configuration TOML depuis `config.toml` (ou le chemin spécifié).
5. Initialise l'extension Flask-SQLAlchemy (`db`).
6. Enregistre les filtres Jinja2 personnalisés (`date_fr` et `day_type_fr`).
7. Enregistre les blueprints de routes (`dashboard`, `entries`, `reports`).
8. Exécute la migration de schéma manuelle (`_migrate_db`) puis crée les tables manquantes (`db.create_all()`).
Paramètres:
config_path (str | None): Chemin optionnel vers le fichier de configuration TOML.
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:
Flask: L'instance de l'application Flask configurée et prête à l'emploi.
"""
app = Flask(__name__, instance_relative_config=True)
os.makedirs(app.instance_path, exist_ok=True)
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
if database_uri is None:
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["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
@@ -79,13 +185,22 @@ def create_app(config_path=None):
else:
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)
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.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
app.register_blueprint(dashboard_bp)
app.register_blueprint(entries_bp)
app.register_blueprint(reports_bp)

View File

@@ -1,34 +1,75 @@
from app import db
from app.models import WorkEntry, LeaveBalance
import sqlalchemy as sa
from datetime import date
import sqlalchemy as sa
from app import db
from app.models import LeaveBalance, WorkEntry
def compute_leave_used(year: int) -> dict[str, int]:
"""
Calcule le nombre de jours de congés et de RTT consommés pour une année donnée.
Cette fonction interroge la base de données pour compter le nombre d'entrées
de journal (`WorkEntry`) de type 'CONGE' et 'RTT' comprises entre le 1er janvier
et le 31 décembre de l'année spécifiée.
Accès DB :
- Lecture seule sur la table `work_entries`.
Paramètres :
year (int) : L'année pour laquelle calculer les jours consommés.
Retour :
dict[str, int] : Un dictionnaire contenant :
- "conges" (int) : Le nombre de jours de congés consommés.
- "rtt" (int) : Le nombre de jours de RTT consommés.
"""
start = date(year, 1, 1)
end = date(year, 12, 31)
conges = db.session.scalar(
sa.select(sa.func.count()).where(
WorkEntry.date.between(start, end),
WorkEntry.day_type == "CONGE",
conges = (
db.session.scalar(
sa.select(sa.func.count()).where(
WorkEntry.date.between(start, end),
WorkEntry.day_type == "CONGE",
)
)
) or 0
or 0
)
rtt = db.session.scalar(
sa.select(sa.func.count()).where(
WorkEntry.date.between(start, end),
WorkEntry.day_type == "RTT",
rtt = (
db.session.scalar(
sa.select(sa.func.count()).where(
WorkEntry.date.between(start, end),
WorkEntry.day_type == "RTT",
)
)
) or 0
or 0
)
return {"conges": conges, "rtt": rtt}
def get_or_create_balance(year: int) -> LeaveBalance:
balance = db.session.scalar(
sa.select(LeaveBalance).where(LeaveBalance.year == year)
)
"""
Récupère le solde annuel des congés et RTT pour une année donnée, ou le crée s'il n'existe pas.
Si aucun solde n'existe pour l'année spécifiée, un nouvel enregistrement `LeaveBalance`
est créé avec les valeurs par défaut (28 jours de congés, 19 jours de RTT) et enregistré
en base de données.
Accès DB :
- Lecture sur la table `leave_balance`.
- Écriture (insertion et commit) si l'enregistrement n'existe pas.
Paramètres :
year (int) : L'année concernée.
Retour :
LeaveBalance : L'objet représentant le solde annuel pour l'année spécifiée.
"""
balance = db.session.scalar(sa.select(LeaveBalance).where(LeaveBalance.year == year))
if balance is None:
balance = LeaveBalance(year=year)
db.session.add(balance)

View File

@@ -1,4 +1,16 @@
import statistics as _stats
def minutes_to_str(minutes: int) -> str:
"""
Convertit une durée en minutes en une chaîne formatée lisible (ex: "7h45" ou "-1h15").
Paramètres :
minutes (int) : Le nombre de minutes à convertir (peut être négatif).
Retour :
str : La chaîne formatée au format "[signe]HhMM".
"""
sign = "-" if minutes < 0 else ""
minutes = abs(minutes)
return f"{sign}{minutes // 60}h{minutes % 60:02d}"
@@ -18,20 +30,51 @@ _REFERENCE_MINUTES = {
def work_minutes_reference(day_type: str) -> int:
"""
Retourne la durée de travail de référence en minutes pour un type de journée donné.
Les durées de référence sont :
- WORK, TT, FORMATION : 7h45 (465 minutes)
- GARDE : 10h00 (600 minutes)
- ASTREINTE, RTT, CONGE, MALADE, FERIE : 0 minute
Paramètres :
day_type (str) : Le type de journée (ex: "WORK", "TT", "CONGE").
Retour :
int : La durée de référence en minutes. Par défaut 465 minutes si le type est inconnu.
"""
return _REFERENCE_MINUTES.get(day_type, 465)
def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int:
"""
Calcule l'écart (solde) entre les minutes réellement travaillées et les minutes de référence.
Paramètres :
actual_minutes (int) : Le total des minutes travaillées.
reference_minutes (int) : Le total des minutes de référence.
Retour :
int : L'écart en minutes (positif si heures supplémentaires, négatif si déficit).
"""
return actual_minutes - reference_minutes
import statistics as _stats
def monthly_stats(entries: list) -> dict:
"""
Calcule médiane journalière et médiane hebdomadaire (semaines ISO)
pour un groupe d'entrées. Les absences (total_minutes=0) sont incluses.
Calcule la médiane journalière et la médiane hebdomadaire (semaines ISO) pour un groupe d'entrées.
Les absences (durée de travail de 0 minute) sont incluses dans les calculs.
Les semaines sont regroupées selon le calendrier ISO (année, numéro de semaine).
Paramètres :
entries (list) : Une liste d'objets `WorkEntry`.
Retour :
dict : Un dictionnaire contenant :
- "median_daily_min" (int) : La médiane des minutes travaillées par jour.
- "median_weekly_min" (int) : La médiane des minutes travaillées par semaine ISO.
"""
if not entries:
return {"median_daily_min": 0, "median_weekly_min": 0}
@@ -50,7 +93,16 @@ def monthly_stats(entries: list) -> dict:
def count_day_types(entries: list) -> dict[str, int]:
"""Retourne un dict {day_type: count} pour une liste d'entrées, sans les zéros."""
"""
Comptabilise le nombre d'occurrences de chaque type de journée dans une liste d'entrées.
Paramètres :
entries (list) : Une liste d'objets `WorkEntry`.
Retour :
dict[str, int] : Un dictionnaire associant chaque type de journée présent à son nombre d'occurrences.
Les types de journées non représentés ne figurent pas dans le dictionnaire.
"""
counts: dict[str, int] = {}
for entry in entries:
counts[entry.day_type] = counts.get(entry.day_type, 0) + 1

View File

@@ -4,9 +4,22 @@ def compute_km_for_entry(
motor_vehicle_id: str | None = None,
) -> dict[str, int]:
"""
Retourne un dict {vehicle_id: km} pour un profil de trajet donné.
La clé générique 'moteur' est remplacée par motor_vehicle_id si fourni.
Retourne {} si pas de profil (TT, CONGE, etc.).
Calcule les distances parcourues par véhicule pour une entrée de journal donnée.
Cette fonction associe un profil de trajet à ses distances configurées. Si le profil
contient une clé générique 'moteur', celle-ci est remplacée par l'identifiant réel du
véhicule motorisé (`motor_vehicle_id`) s'il est fourni. Si aucun véhicule motorisé n'est
fourni, la distance associée à la clé 'moteur' est ignorée.
Paramètres :
journey_profile_id (str | None) : L'identifiant du profil de trajet (ex: "domicile_travail").
Si None, retourne un dictionnaire vide.
journeys (dict) : La configuration des trajets (généralement issue de config.toml).
motor_vehicle_id (str | None) : L'identifiant du véhicule motorisé utilisé (ex: "citadine").
Retour :
dict[str, int] : Un dictionnaire associant chaque identifiant de véhicule (ex: "velo", "citadine")
à la distance parcourue en kilomètres.
"""
if not journey_profile_id:
return {}
@@ -23,7 +36,18 @@ def compute_km_for_entry(
def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float:
"""Calcule le CO2 total en grammes pour un dict {vehicle_id: km}."""
"""
Calcule la quantité totale de CO2 émise en grammes pour un ensemble de distances parcourues.
Paramètres :
km_by_vehicle (dict[str, int]) : Un dictionnaire associant chaque identifiant de véhicule
à la distance parcourue en kilomètres.
vehicles (dict) : La configuration des véhicules (généralement issue de config.toml)
contenant le taux d'émission de CO2 par kilomètre (`co2_per_km`).
Retour :
float : La quantité totale de CO2 émise en grammes.
"""
total = 0.0
for vehicle_id, km in km_by_vehicle.items():
vehicle = vehicles.get(vehicle_id, {})
@@ -32,11 +56,26 @@ def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float:
return total
def compute_frais_reels(total_km_moteur: float, tranches: list[dict], electric: bool = False) -> float:
def compute_frais_reels(
total_km_moteur: float, tranches: list[dict], electric: bool = False
) -> float:
"""
Calcule les frais réels fiscaux selon le barème kilométrique.
km_max = 0 signifie "pas de limite" (dernière tranche).
electric=True applique la majoration de 20 % pour véhicules électriques.
Calcule le montant des frais réels déductibles selon le barème kilométrique fiscal.
Le calcul s'effectue tranche par tranche en fonction du kilométrage annuel total parcouru
avec un véhicule motorisé. Une tranche avec `km_max = 0` signifie l'absence de limite
supérieure et est conventionnellement la dernière tranche du barème.
Une majoration de 20 % est appliquée sur le montant final si le véhicule est électrique.
Paramètres :
total_km_moteur (float) : Le kilométrage annuel total parcouru avec le véhicule motorisé.
tranches (list[dict]) : La liste des tranches du barème kilométrique pour la puissance fiscale
du véhicule (ex: taux, forfait, km_max).
electric (bool) : Indique si le véhicule est électrique (applique une majoration de 20 % si True).
Retour :
float : Le montant total calculé des frais réels en euros.
"""
if not tranches or total_km_moteur <= 0:
return 0.0

View File

@@ -1,21 +1,156 @@
"""Module de chargement et d'accès à la configuration TOML de l'application.
Ce module sert d'interface entre l'application Flask et le fichier `config.toml`.
Toute la configuration des véhicules, des trajets et du barème kilométrique y est stockée
et chargée au démarrage dans `app.config["TOML"]`.
Contrat TOML :
1. Véhicules (`[vehicles]`) :
- Chaque véhicule possède un identifiant unique (clé).
- Attributs : `name` (nom d'affichage), `type` ("moteur" ou "velo"), `fuel` ("electric", "essence", etc.),
`co2_per_km` (émissions de CO2 en grammes par km), et optionnellement `cv` (puissance fiscale pour le barème kilométrique).
2. Trajets (`[journeys]`) :
- Profils de trajets prédéfinis (ex: "moteur_seul").
- Attributs : `name` (nom d'affichage), `distances` (dictionnaire associant un type de véhicule à une distance en km).
3. Barème kilométrique (`[bareme_kilometrique.YYYY]`) :
- Organisé par année (ex: "2026") puis par puissance fiscale (`cv_3`, `cv_4`, `cv_5`, `cv_6`, `cv_7plus`).
- Chaque catégorie contient une liste de `tranches` définissant les formules de calcul des frais réels.
- Une tranche possède : `km_max` (limite supérieure de la tranche, `km_max = 0` signifie "pas de limite supérieure"),
`taux` (coefficient multiplicateur par km) et `forfait` (montant forfaitaire à ajouter).
4. Types de jours sans trajet :
- 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
_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():
"""Récupère l'ensemble des véhicules configurés dans le fichier TOML.
Retourne:
dict: Un dictionnaire des véhicules où la clé est l'identifiant du véhicule
et la valeur est un dictionnaire contenant ses propriétés (name, type, fuel, co2_per_km, cv).
Retourne un dictionnaire vide si aucune configuration n'est chargée.
"""
return current_app.config.get("TOML", {}).get("vehicles", {})
def get_motor_vehicles():
"""Retourne uniquement les véhicules de type 'moteur'."""
"""Filtre et retourne uniquement les véhicules de type 'moteur'.
Cette fonction exclut les véhicules alternatifs (comme les vélos) pour ne conserver
que ceux qui possèdent une puissance fiscale (CV) et sont éligibles au barème kilométrique.
Retourne:
dict: Un dictionnaire contenant uniquement les véhicules dont le type est 'moteur'.
"""
return {k: v for k, v in get_vehicles().items() if v.get("type") == "moteur"}
def get_journeys():
"""Récupère l'ensemble des profils de trajets configurés dans le fichier TOML.
Retourne:
dict: Un dictionnaire des trajets où la clé est l'identifiant du trajet
et la valeur est un dictionnaire contenant ses propriétés (name, distances).
Retourne un dictionnaire vide si aucune configuration n'est chargée.
"""
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:
"""Retourne True si le profil de trajet inclut un véhicule à moteur."""
"""Vérifie si un profil de trajet donné inclut une distance pour véhicule à moteur.
Cette validation permet de déterminer si l'utilisateur doit sélectionner un véhicule
à moteur lors de la saisie d'une journée de travail avec ce trajet.
Paramètres:
journey_profile_id (str | None): L'identifiant du profil de trajet à vérifier.
Retourne:
bool: True si le trajet existe et définit une distance pour la clé 'moteur',
False sinon ou si l'identifiant est nul.
"""
if not journey_profile_id:
return False
journeys = get_journeys()
@@ -24,6 +159,24 @@ def journey_has_motor(journey_profile_id: str | None) -> bool:
def get_bareme(year: int, cv: int) -> list[dict]:
"""Récupère les tranches du barème kilométrique pour une année et une puissance fiscale données.
Le barème kilométrique officiel est structuré en tranches de distances annuelles.
Cette fonction sélectionne la bonne catégorie de puissance fiscale (CV) :
- cv <= 3 -> 'cv_3'
- cv == 4 -> 'cv_4'
- cv == 5 -> 'cv_5'
- cv == 6 -> 'cv_6'
- cv >= 7 -> 'cv_7plus'
Paramètres:
year (int): L'année civile concernée par le calcul.
cv (int): La puissance fiscale du véhicule en chevaux fiscaux.
Retourne:
list[dict]: Une liste de dictionnaires représentant les tranches applicables.
Chaque tranche contient 'km_max' (0 si pas de limite), 'taux' et 'forfait'.
"""
bareme = current_app.config.get("TOML", {}).get("bareme_kilometrique", {})
year_data = bareme.get(str(year), {})
if cv <= 3:
@@ -40,4 +193,12 @@ def get_bareme(year: int, cv: int) -> list[dict]:
def day_types_without_journey():
"""Retourne l'ensemble des types de jours qui n'impliquent aucun trajet physique.
Ces types de jours (Télétravail, Maladie, Congé, RTT, Férié) sont exemptés de la saisie
de trajets ou de véhicules, car le travail s'effectue à distance ou l'employé est absent.
Retourne:
set[str]: Un ensemble de codes de types de jours (ex: {"TT", "MALADE", ...}).
"""
return {"TT", "MALADE", "CONGE", "RTT", "FERIE"}

View File

@@ -1,10 +1,24 @@
from app import db
from datetime import date, time, datetime
from datetime import UTC, date, datetime, time
import sqlalchemy as sa
import sqlalchemy.orm as so
from app import db
class WorkEntry(db.Model):
"""
Représente une entrée de journal de travail pour une journée unique.
Cette classe stocke les informations relatives à une journée de travail,
notamment la date, le type de journée (WORK, TT, GARDE, ASTREINTE, etc.),
les profils de trajet domicile-travail, le véhicule utilisé, un commentaire
et les plages horaires associées.
Invariants :
- La date est unique (une seule entrée par jour).
"""
__tablename__ = "work_entries"
id: so.Mapped[int] = so.mapped_column(primary_key=True)
@@ -13,16 +27,32 @@ class WorkEntry(db.Model):
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")
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(
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(
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:
"""
Calcule la durée totale travaillée dans la journée en minutes.
Cette méthode somme la durée de toutes les plages horaires (`TimeSlot`)
associées à cette entrée. Elle gère le passage de minuit : si l'heure de fin
d'une plage est inférieure ou égale à son heure de début, la plage est
considérée comme se terminant le lendemain (ajout de 24 heures).
Retour :
int : La durée totale en minutes.
"""
total = 0
for slot in self.time_slots:
start = slot.start_time.hour * 60 + slot.start_time.minute
@@ -33,11 +63,24 @@ class WorkEntry(db.Model):
return total
def total_hours_str(self) -> str:
"""
Retourne la durée totale travaillée sous forme de chaîne formatée (ex: "7h45").
Retour :
str : La durée formatée au format "HhMM".
"""
minutes = self.total_minutes()
return f"{minutes // 60}h{minutes % 60:02d}"
class TimeSlot(db.Model):
"""
Représente une plage horaire de travail au sein d'une journée.
Chaque plage possède une heure de début et une heure de fin. Elle est rattachée
à une entrée de journal (`WorkEntry`).
"""
__tablename__ = "time_slots"
id: so.Mapped[int] = so.mapped_column(primary_key=True)
@@ -46,9 +89,60 @@ 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):
"""
Représente le solde annuel des congés et RTT pour une année donnée.
Stocke les quotas initiaux/totaux de congés payés et de RTT alloués pour l'année.
Par défaut, un utilisateur bénéficie de 28 jours de congés et 19 jours de RTT.
"""
__tablename__ = "leave_balance"
id: so.Mapped[int] = so.mapped_column(primary_key=True)

View File

@@ -0,0 +1,7 @@
"""Package contenant les blueprints de routes de l'application.
Ce package regroupe les différents modules de routes (blueprints) de l'application :
- `dashboard` : Gère l'affichage du tableau de bord principal.
- `entries` : Gère la saisie, la modification et la suppression des entrées de temps.
- `reports` : Gère la génération des rapports d'activité annuels et mensuels.
"""

View File

@@ -1,18 +1,48 @@
from flask import Blueprint, render_template
"""Blueprint des routes du tableau de bord principal.
Ce module gère l'affichage de la page d'accueil (le tableau de bord). Il calcule et rassemble
les statistiques clés de la semaine courante et du mois en cours, ainsi que le solde des congés
et RTT pour l'année civile.
"""
from datetime import date, timedelta
import sqlalchemy as sa
from flask import Blueprint, render_template
from app import db
from app.models import WorkEntry
from app.business.time_calc import minutes_to_str, work_minutes_reference
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams
from app.business.leave_calc import compute_leave_used, get_or_create_balance
from app.config_loader import get_vehicles, get_journeys
from app.business.time_calc import minutes_to_str, work_minutes_reference
from app.business.travel_calc import compute_co2_grams, compute_km_for_entry
from app.config_loader import get_journeys, get_vehicles
from app.models import WorkEntry
bp = Blueprint("dashboard", __name__)
@bp.route("/")
def index():
"""Affiche le tableau de bord principal de l'utilisateur.
Cette route effectue les opérations suivantes :
1. Détermine la date du jour et calcule les limites de la semaine courante (du lundi au dimanche).
2. Récupère toutes les entrées de temps (`WorkEntry`) de la semaine courante pour calculer :
- Le temps de travail effectif cumulé (`week_actual`).
- Le temps de travail de référence théorique (`week_ref`) selon le type de chaque journée.
- Le solde d'heures de la semaine (`week_balance` = effectif - référence).
3. Récupère toutes les entrées de temps du mois en cours (du 1er jour du mois jusqu'à aujourd'hui) pour calculer :
- Les distances parcourues par véhicule (`month_km`) à partir des profils de trajets associés.
- Les émissions de CO2 correspondantes (`month_co2`).
4. Récupère ou initialise le solde annuel des congés et RTT (`balance`) et calcule les jours posés/utilisés (`used`).
5. Vérifie s'il existe déjà une entrée de temps pour la journée d'aujourd'hui (`today_entry`).
6. Rend le template `dashboard.html` avec l'ensemble de ces données de contexte.
Méthode HTTP :
GET
Retourne:
str: Le rendu HTML de la page du tableau de bord (`dashboard.html`).
"""
today = date.today()
year = today.year
@@ -45,9 +75,7 @@ def index():
balance = get_or_create_balance(year)
used = compute_leave_used(year)
today_entry = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == today)
)
today_entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == today))
return render_template(
"dashboard.html",

View File

@@ -1,9 +1,34 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
"""Blueprint des routes de gestion des entrées de temps (WorkEntry).
Ce module gère le cycle de vie complet des entrées journalières de travail :
- Affichage de la liste historique des entrées.
- Création d'une nouvelle entrée (formulaire et traitement POST).
- Modification d'une entrée existante (formulaire pré-rempli et traitement POST).
- Suppression d'une entrée.
Règles de validation et de cohérence des données :
1. Unicité de la date : Une seule entrée (`WorkEntry`) est autorisée par jour.
2. Types de jours sans trajet : Si le type de jour est dans `day_types_without_journey()` (TT, MALADE, CONGE, RTT, FERIE),
le profil de trajet (`journey_profile_id`) est forcé à `None`.
3. Véhicule à moteur : Si le profil de trajet sélectionné n'inclut pas de véhicule à moteur (vérifié via `journey_has_motor`),
le véhicule à moteur (`motor_vehicle_id`) est forcé à `None`.
4. Plages horaires : Les plages horaires (`TimeSlot`) existantes d'une entrée sont supprimées et recréées à chaque soumission
pour simplifier la mise à jour des plages multiples.
"""
from datetime import date, time
import sqlalchemy as sa
from flask import Blueprint, flash, redirect, render_template, request, url_for
from app import db
from app.models import WorkEntry, TimeSlot
from app.config_loader import get_journeys, get_motor_vehicles, day_types_without_journey, journey_has_motor
from app.config_loader import (
day_types_without_journey,
get_journeys,
get_motor_vehicles,
journey_has_motor,
)
from app.models import TimeSlot, WorkEntry
bp = Blueprint("entries", __name__, url_prefix="/entries")
@@ -22,15 +47,55 @@ DAY_TYPES = [
@bp.route("/")
def list_entries():
entries = db.session.scalars(
sa.select(WorkEntry).order_by(WorkEntry.date.desc())
).all()
"""Affiche la liste historique de toutes les entrées de temps enregistrées.
Cette route récupère l'ensemble des entrées (`WorkEntry`) triées par date décroissante
et les transmet au template pour affichage sous forme de tableau ou de liste.
Méthode HTTP :
GET
Retourne:
str: Le rendu HTML de la liste des entrées (`entry_list.html`).
"""
entries = db.session.scalars(sa.select(WorkEntry).order_by(WorkEntry.date.desc())).all()
return render_template("entry_list.html", entries=entries)
@bp.route("/new", methods=["GET", "POST"])
@bp.route("/<int:entry_id>/edit", methods=["GET", "POST"])
def entry_form(entry_id=None):
"""Gère l'affichage du formulaire et l'enregistrement (création ou modification) d'une entrée.
Cette route est doublement mappée pour la création (`/new`) et l'édition (`/<entry_id>/edit`).
Comportement en GET :
- Si `entry_id` est fourni, récupère l'entrée correspondante en base de données. Si elle n'existe pas,
affiche un message d'erreur et redirige vers la liste des entrées.
- Prépare le contexte nécessaire au formulaire : liste des types de jours, profils de trajets,
véhicules à moteur disponibles, types de jours sans trajet, et la date du jour par défaut.
- Rend le template `entry_form.html`.
Comportement en POST :
- Extrait et valide les données du formulaire : date, type de jour, trajet, véhicule à moteur, commentaire.
- Applique les règles de cohérence (mise à `None` du trajet ou du véhicule si les conditions ne sont pas remplies).
- En création : vérifie qu'aucune entrée n'existe déjà à cette date. Si c'est le cas, affiche une erreur.
- Enregistre ou met à jour l'objet `WorkEntry` en base de données.
- Supprime toutes les plages horaires (`TimeSlot`) existantes associées à cette entrée.
- Parcourt les listes d'heures de début (`start_time`) et de fin (`end_time`) soumises, et recrée les objets
`TimeSlot` valides associés à l'entrée.
- Valide la transaction en base de données (`db.session.commit()`), affiche un message de succès
et redirige vers le tableau de bord.
Paramètres:
entry_id (int | None): L'identifiant de l'entrée à modifier, ou None pour une nouvelle entrée.
Méthodes HTTP :
GET, POST
Retourne:
str | Response: Le rendu HTML du formulaire (GET) ou une redirection HTTP (POST / erreur).
"""
entry = None
if entry_id:
entry = db.session.get(WorkEntry, entry_id)
@@ -51,9 +116,7 @@ def entry_form(entry_id=None):
journey_profile_id = None
if entry is None:
existing = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == entry_date)
)
existing = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == entry_date))
if existing:
flash(f"Une entrée existe déjà pour le {entry_date}.", "error")
return redirect(url_for("entries.entry_form"))
@@ -72,11 +135,13 @@ def entry_form(entry_id=None):
ends = request.form.getlist("end_time")
for s, e in zip(starts, ends):
if s and e:
db.session.add(TimeSlot(
entry=entry,
start_time=time.fromisoformat(s),
end_time=time.fromisoformat(e),
))
db.session.add(
TimeSlot(
entry=entry,
start_time=time.fromisoformat(s),
end_time=time.fromisoformat(e),
)
)
db.session.commit()
flash("Entrée enregistrée.", "success")
@@ -96,6 +161,21 @@ def entry_form(entry_id=None):
@bp.route("/<int:entry_id>/delete", methods=["POST"])
def delete_entry(entry_id):
"""Supprime une entrée de temps existante.
Cette route récupère l'entrée par son identifiant, la supprime de la base de données
(les plages horaires associées sont également supprimées en cascade si configuré, ou gérées par SQLAlchemy),
valide la transaction, affiche un message de succès et redirige vers la liste des entrées.
Paramètres:
entry_id (int): L'identifiant de l'entrée à supprimer.
Méthode HTTP :
POST (sécurisé contre les suppressions accidentelles via GET)
Retourne:
Response: Une redirection HTTP vers la liste des entrées (`entries.list_entries`).
"""
entry = db.session.get(WorkEntry, entry_id)
if entry:
db.session.delete(entry)

View File

@@ -1,24 +1,71 @@
from flask import Blueprint, render_template, request
from datetime import date
"""Blueprint des routes de génération des rapports et statistiques.
Ce module gère l'affichage des rapports annuels et mensuels. Il calcule les distances cumulées,
les émissions de CO2, les frais réels selon le barème kilométrique officiel, et compile les statistiques
de temps de travail (médianes quotidiennes et hebdomadaires) pour chaque mois de l'année sélectionnée.
"""
from collections import defaultdict
from datetime import date
import sqlalchemy as sa
from flask import Blueprint, render_template, request
from app import db
from app.business.time_calc import count_day_types, minutes_to_str, monthly_stats
from app.business.travel_calc import compute_co2_grams, compute_frais_reels, compute_km_for_entry
from app.config_loader import get_bareme, get_journeys, get_vehicles
from app.models import WorkEntry
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels
from app.business.time_calc import count_day_types, monthly_stats, minutes_to_str
from app.config_loader import get_vehicles, get_journeys, get_bareme
bp = Blueprint("reports", __name__, url_prefix="/reports")
MONTHS_FR = {
1: "Janvier", 2: "Février", 3: "Mars", 4: "Avril",
5: "Mai", 6: "Juin", 7: "Juillet", 8: "Août",
9: "Septembre", 10: "Octobre", 11: "Novembre", 12: "Décembre",
1: "Janvier",
2: "Février",
3: "Mars",
4: "Avril",
5: "Mai",
6: "Juin",
7: "Juillet",
8: "Août",
9: "Septembre",
10: "Octobre",
11: "Novembre",
12: "Décembre",
}
@bp.route("/")
def index():
"""Génère et affiche le rapport d'activité annuel et mensuel.
Cette route effectue les opérations suivantes :
1. Récupère l'année cible depuis les paramètres de requête HTTP GET (`year`). Par défaut, utilise l'année en cours.
2. Récupère toutes les entrées de temps (`WorkEntry`) de cette année civile.
3. Récupère la configuration des véhicules et des trajets depuis le fichier TOML.
4. Calcule les statistiques annuelles cumulées :
- Distances parcourues par véhicule (`total_km`).
- Émissions de CO2 totales en grammes (`total_co2`), converties ensuite en kilogrammes.
- Frais réels remboursables par véhicule (`frais_reels`) en appliquant le barème kilométrique officiel
de l'année correspondante (avec une majoration de +20% pour les véhicules électriques).
- Nombre de jours par type de journée (`day_type_counts`).
5. Regroupe les entrées par mois pour calculer les statistiques mensuelles :
- Nom du mois en français.
- Nombre de jours saisis.
- Distances parcourues par véhicule et distance totale du mois.
- Durée quotidienne médiane de travail et durée hebdomadaire médiane de travail (formatées en chaînes "HHhMM").
6. Rend le template `reports.html` avec l'ensemble de ces données de contexte.
Méthode HTTP :
GET
Paramètres de requête (Query Params) :
year (int, optionnel) : L'année civile pour laquelle générer le rapport (ex: `?year=2026`).
Par défaut, l'année courante.
Retourne:
str: Le rendu HTML de la page des rapports (`reports.html`).
"""
year = request.args.get("year", date.today().year, type=int)
start = date(year, 1, 1)
end = date(year, 12, 31)
@@ -73,7 +120,9 @@ def index():
"km_by_vehicle": month_km,
"km_total": sum(month_km.values()),
"median_daily_str": minutes_to_str(stats["median_daily_min"]) if month_entries else "",
"median_weekly_str": minutes_to_str(stats["median_weekly_min"]) if month_entries else "",
"median_weekly_str": minutes_to_str(stats["median_weekly_min"])
if month_entries
else "",
}
return render_template(

View File

@@ -37,6 +37,12 @@ distances = { moteur = 14, velo = 8 }
name = "Vélo seul"
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) ---
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
@@ -115,3 +121,82 @@ forfait = 1515
km_max = 0
taux = 0.470
forfait = 0
# --- Barème kilométrique voitures 2026 (revenus 2025) ---
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
[[bareme_kilometrique.2026.cv_3.tranches]]
km_max = 5000
taux = 0.529
forfait = 0
[[bareme_kilometrique.2026.cv_3.tranches]]
km_max = 20000
taux = 0.316
forfait = 1065
[[bareme_kilometrique.2026.cv_3.tranches]]
km_max = 0
taux = 0.370
forfait = 0
[[bareme_kilometrique.2026.cv_4.tranches]]
km_max = 5000
taux = 0.606
forfait = 0
[[bareme_kilometrique.2026.cv_4.tranches]]
km_max = 20000
taux = 0.340
forfait = 1330
[[bareme_kilometrique.2026.cv_4.tranches]]
km_max = 0
taux = 0.407
forfait = 0
[[bareme_kilometrique.2026.cv_5.tranches]]
km_max = 5000
taux = 0.636
forfait = 0
[[bareme_kilometrique.2026.cv_5.tranches]]
km_max = 20000
taux = 0.357
forfait = 1395
[[bareme_kilometrique.2026.cv_5.tranches]]
km_max = 0
taux = 0.427
forfait = 0
[[bareme_kilometrique.2026.cv_6.tranches]]
km_max = 5000
taux = 0.665
forfait = 0
[[bareme_kilometrique.2026.cv_6.tranches]]
km_max = 20000
taux = 0.374
forfait = 1457
[[bareme_kilometrique.2026.cv_6.tranches]]
km_max = 0
taux = 0.447
forfait = 0
[[bareme_kilometrique.2026.cv_7plus.tranches]]
km_max = 5000
taux = 0.697
forfait = 0
[[bareme_kilometrique.2026.cv_7plus.tranches]]
km_max = 20000
taux = 0.394
forfait = 1515
[[bareme_kilometrique.2026.cv_7plus.tranches]]
km_max = 0
taux = 0.470
forfait = 0

295
docs/onboarding.md Normal file
View File

@@ -0,0 +1,295 @@
# Guide d'intégration (Onboarding) pour les développeurs
Bienvenue sur le projet **Tableau de bord pro** ! Ce guide est conçu pour t'aider à prendre en main rapidement l'application, comprendre son architecture, ses règles métier et savoir comment y apporter des modifications courantes.
---
## 1. Objectif fonctionnel
Le **Tableau de bord pro** est une application web personnelle permettant de suivre :
- **Le temps de travail quotidien** : saisie des heures travaillées, des types de journées (travail, télétravail, garde, astreinte, formation, RTT, congé, maladie, férié) et des commentaires associés.
- **Les déplacements professionnels** : calcul automatique des distances parcourues par véhicule, estimation des émissions de CO₂ et calcul des frais réels déductibles selon le barème kilométrique fiscal officiel.
- **Le solde des congés et RTT** : suivi annuel des jours posés et du solde restant par rapport aux quotas alloués.
- **Les rapports d'activité** : bilans annuels et mensuels compilant les kilomètres, le CO₂, les frais réels et la répartition des types de journées.
---
## 2. Prérequis et démarrage local
### Prérequis
- **Python 3.11+** installé sur ta machine.
### Démarrage local
Pour lancer l'application en mode développement, exécute les commandes suivantes dans ton terminal :
```bash
# 1. Créer l'environnement virtuel Python
python -m venv .venv
# 2. Activer l'environnement virtuel et installer les dépendances
.venv/bin/pip install -r requirements.txt
# 3. Lancer le serveur de développement Flask
.venv/bin/python run.py
```
L'application est alors accessible localement à l'adresse suivante : [http://localhost:5000](http://localhost:5000).
---
## 3. Architecture de l'application
L'application est construite avec le framework **Flask** en utilisant le *Factory Pattern* (via la fonction `create_app` dans `app/__init__.py`).
### Structure des dossiers et fichiers clés
- `run.py` : Point d'entrée pour lancer le serveur de développement.
- `config.toml` : Fichier de configuration statique (véhicules, trajets, barèmes fiscaux).
- `app/` : Code source de l'application.
- `__init__.py` : Initialisation de Flask, configuration de la base de données, enregistrement des blueprints et des filtres Jinja2.
- `config_loader.py` : Interface de lecture du fichier `config.toml`.
- `models.py` : Définition des modèles de données SQLAlchemy (SQLite).
- `business/` : Logique métier pure, **sans dépendance à Flask** (facile à tester) :
- `time_calc.py` : Calculs de durées, de soldes d'heures et statistiques de temps.
- `travel_calc.py` : Calculs de distances, d'émissions de CO₂ et de frais réels.
- `leave_calc.py` : Calculs de congés et RTT consommés.
- `routes/` : Contrôleurs Flask gérant les requêtes HTTP et le rendu des templates :
- `dashboard.py` : Page d'accueil et vue d'ensemble.
- `entries.py` : Gestion (CRUD) des entrées de temps.
- `reports.py` : Génération des rapports annuels et mensuels.
- `templates/` : Fichiers HTML utilisant Jinja2.
- `tests/` : Tests unitaires et d'intégration (pytest).
### Technologies Frontend
- **Tailwind CSS (via CDN)** : Utilisé pour le style. Les variables de design et les classes personnalisées (comme `.card`, `.btn-primary`, etc.) sont définies directement dans la balise `<style>` de `app/templates/base.html`.
- **HTMX** : Utilisé pour rendre l'interface dynamique sans rechargement complet de page.
- **JavaScript** : Uniquement du JS inline dans `app/templates/entry_form.html` pour la gestion dynamique du formulaire de saisie.
### Authentification
L'application n'intègre **aucun système d'authentification interne**. La sécurité et l'authentification sont entièrement déléguées à un serveur **HAProxy** situé en amont (upstream) en production.
---
## 4. Flux d'une saisie de journée
Voici ce qui se passe lorsqu'un utilisateur saisit ou modifie une journée de travail :
1. **Affichage du formulaire (GET `/entries/new` ou `/entries/<id>/edit`)** :
- La route `entry_form` dans `app/routes/entries.py` récupère les véhicules à moteur et les profils de trajets depuis `config.toml` via `app/config_loader.py`.
- Elle transmet ces données au template `entry_form.html`.
2. **Soumission du formulaire (POST)** :
- L'utilisateur envoie la date, le type de journée, le trajet, le véhicule à moteur utilisé, le commentaire et les plages horaires.
- **Application des règles de cohérence** :
- Si le type de journée est un jour sans trajet (ex: Télétravail, Congé), le trajet est forcé à `None`.
- Si le trajet sélectionné n'implique pas de véhicule à moteur, le véhicule à moteur est forcé à `None`.
- **Enregistrement de l'entrée (`WorkEntry`)** :
- En création, l'application vérifie qu'aucune entrée n'existe déjà à cette date (unicité de la date).
- L'entrée est créée ou mise à jour dans la table `work_entries`.
- **Gestion des plages horaires (`TimeSlot`)** :
- Pour simplifier la mise à jour, toutes les plages horaires existantes de cette journée sont **supprimées** de la base de données.
- Les nouvelles plages horaires soumises sont lues, validées et recréées sous forme d'objets `TimeSlot` liés à la `WorkEntry`.
- **Validation** : La transaction est validée via `db.session.commit()`, et l'utilisateur est redirigé vers le tableau de bord.
---
## 5. Rôle de `config.toml` vs SQLite
L'application sépare strictement la configuration métier statique des données utilisateur dynamiques.
### `config.toml` (Configuration statique)
Ce fichier contient les paramètres qui ne changent pas fréquemment et qui définissent les règles de calcul :
- **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.
- **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"]`.*
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)
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.
---
## 6. Règles métier essentielles
Pour éviter les erreurs de calcul, garde bien en tête ces règles fondamentales :
- **Types de journées** : Les types autorisés sont `WORK`, `TT`, `GARDE`, `ASTREINTE`, `FORMATION`, `RTT`, `CONGE`, `MALADE`, `FERIE`.
- **Absence de trajet** : Les types `TT`, `MALADE`, `CONGE`, `RTT` et `FERIE` n'impliquent aucun trajet physique (définis dans `day_types_without_journey()`).
- **Durée de référence du travail** :
- `WORK`, `TT`, `FORMATION` : 7h45 (soit 465 minutes).
- `GARDE` : 10h00 (soit 600 minutes).
- `ASTREINTE`, `RTT`, `CONGE`, `MALADE`, `FERIE` : 0 minute.
- **Calcul du temps de travail** : La méthode `total_minutes()` de `WorkEntry` somme les durées des `TimeSlot`. Elle gère le **passage de minuit** : si l'heure de fin est inférieure ou égale à l'heure de début (ex: 22:00 à 02:00), elle ajoute automatiquement 24 heures à la plage.
- **Frais réels et véhicules électriques** :
- Le calcul des frais réels applique les tranches du barème kilométrique de `config.toml`. Une tranche avec `km_max = 0` représente la tranche supérieure sans limite.
- Les véhicules électriques (`fuel = "electric"`) bénéficient d'une **majoration automatique de 20 %** sur le montant calculé des frais réels (géré dans `app/business/travel_calc.py`).
---
## 7. Tests et Qualité du code
### Exécuter les tests
Les tests sont écrits avec **pytest**. Les tests de logique métier (`test_time_calc.py`, `test_travel_calc.py`) n'ont aucune dépendance à Flask et s'exécutent très rapidement. Les tests de routes utilisent des fixtures définies dans `tests/conftest.py` (base de données SQLite en mémoire et configuration TOML temporaire).
```bash
# Exécuter tous les tests
.venv/bin/python -m pytest
# Exécuter un fichier de test spécifique
.venv/bin/python -m pytest tests/test_time_calc.py -v
# Exécuter un test unitaire précis
.venv/bin/python -m pytest tests/test_routes.py::test_create_entry -v
```
### Qualité et Formatage du code
Le projet utilise **Ruff** comme outil unique pour le linting, le tri des imports et le formatage du code. La configuration est définie dans `pyproject.toml`.
```bash
# Vérifier le code (linter)
.venv/bin/ruff check .
# Vérifier le formatage
.venv/bin/ruff format --check .
# Appliquer automatiquement le formatage et corriger les imports
.venv/bin/ruff format .
.venv/bin/ruff check --fix .
```
---
## 8. Guide de modifications courantes
### A. Ajouter ou modifier un véhicule
Pour ajouter un nouveau véhicule (par exemple, une nouvelle voiture de 4 CV), ouvre `config.toml` et ajoute une section sous `[vehicles]` :
```toml
[vehicles.ma_nouvelle_voiture]
name = "Ma Nouvelle Voiture"
fuel = "essence"
co2_per_km = 120
cv = 4
type = "moteur"
```
### B. Ajouter ou modifier un trajet prédéfini
Pour ajouter un trajet combinant voiture et vélo, ouvre `config.toml` et ajoute une section sous `[journeys]` :
```toml
[journeys.mon_nouveau_trajet]
name = "Mon Nouveau Trajet"
distances = { moteur = 10, velo = 5 }
```
### C. Mettre à jour le barème kilométrique annuel
Chaque année, l'administration fiscale publie un nouveau barème. Pour l'ajouter (par exemple pour l'année 2027), ajoute les tranches correspondantes dans `config.toml` :
```toml
[[bareme_kilometrique.2027.cv_4.tranches]]
km_max = 5000
taux = 0.606
forfait = 0
[[bareme_kilometrique.2027.cv_4.tranches]]
km_max = 20000
taux = 0.340
forfait = 1330
[[bareme_kilometrique.2027.cv_4.tranches]]
km_max = 0
taux = 0.407
forfait = 0
```
### D. Ajouter un nouveau type de journée
Si tu dois ajouter un nouveau type de journée (par exemple `REPOS`) :
1. Ouvre `app/routes/entries.py` et ajoute le type à la liste `DAY_TYPES` :
```python
DAY_TYPES = [
...
("REPOS", "Repos"),
]
```
2. Ouvre `app/__init__.py` et ajoute sa traduction française dans `_DAY_TYPE_LABELS` :
```python
_DAY_TYPE_LABELS = {
...
"REPOS": "Repos",
}
```
3. Ouvre `app/business/time_calc.py` et définis sa durée de référence dans `_REFERENCE_MINUTES` (ex: 0 minute pour un repos) :
```python
_REFERENCE_MINUTES = {
...
"REPOS": 0,
}
```
4. Si ce type de journée n'implique aucun trajet, ouvre `app/config_loader.py` et ajoute-le dans `day_types_without_journey()` :
```python
def day_types_without_journey():
return {"TT", "MALADE", "CONGE", "RTT", "FERIE", "REPOS"}
```
---
## 9. Déploiement, limites et migrations
### Déploiement en production
L'application est déployée dans `/var/www/tableau-de-bord-pro/` et est gérée par un service **systemd** qui lance **Gunicorn**.
Pour mettre à jour la production :
```bash
# 1. Copier les fichiers (en excluant l'environnement virtuel et la base de données locale)
sudo rsync -a --exclude='.venv' --exclude='instance' . /var/www/tableau-de-bord-pro/
# 2. Se positionner dans le dossier de production
cd /var/www/tableau-de-bord-pro
# 3. Mettre à jour les dépendances si nécessaire
sudo .venv/bin/pip install -r requirements.txt
# 4. S'assurer que les permissions sont correctes
sudo chown -R www-data:www-data /var/www/tableau-de-bord-pro
# 5. Redémarrer le service systemd
sudo systemctl restart tableau-de-bord-pro
```
### Limites et Migrations de base de données
- **Pas d'Alembic** : Le projet n'utilise pas d'outil de migration automatique comme Alembic. La base de données est initialisée avec `db.create_all()`.
- **Changement de schéma** : Si tu modifies un modèle dans `app/models.py` (par exemple en ajoutant un champ) :
- 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.
### Horodatages de métadonnées et calculs de durée métier
- **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.
---
## 10. Liens utiles
Pour aller plus loin, n'hésite pas à consulter les documents suivants à la racine du projet :
- [README.md](../README.md) : Présentation générale, instructions d'installation détaillées et script d'import CSV en masse.
- [AGENTS.md](../AGENTS.md) : Guide de développement et consignes pour les agents d'intelligence artificielle travaillant sur ce dépôt.

View File

@@ -312,7 +312,7 @@ def _migrate_db(app):
conn.close()
with app.app_context():
engine = db.get_engine()
engine = db.engine
if "motor_vehicle_id" not in columns:
with engine.connect() as conn:
conn.execute(sa.text(

View File

@@ -384,7 +384,7 @@ git commit -m "feat: TOML config loader for vehicles, journeys, and tax scales"
```python
from app import db
from datetime import date, time, datetime
from datetime import date, time, datetime, UTC
from enum import Enum as PyEnum
import sqlalchemy as sa
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)
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)
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(
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(

View 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.

16
pyproject.toml Normal file
View File

@@ -0,0 +1,16 @@
[tool.ruff]
target-version = "py311"
line-length = 100
include = ["*.py"]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]
# Cet import différé est intentionnel : il prépare sys.path pour le script
# lancé directement (imports des modules de l'app après insertion du parent).
[tool.ruff.lint.per-file-ignores]
"scripts/import_csv.py" = ["E402"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"

View File

@@ -3,3 +3,4 @@ flask-sqlalchemy>=3.1
pytest>=8.0
pytest-flask>=1.3
gunicorn>=22.0
ruff>=0.9,<1.0

166
scripts/import_csv.py Executable file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Script d'import CSV vers la base de données SQLite.
Format CSV attendu :
date,day_type,journey_profile_id,motor_vehicle_id,start_time,end_time,comment
Exemple :
2025-06-02,WORK,moteur_seul,familiale,09:00;14:00,17:45;12:00,Travail normal
2025-06-03,TT,,,09:00,17:45,Télétravail
Pour plusieurs plages horaires, séparer les heures par des points-virgules (;).
Types de jour valides : WORK, TT, GARDE, ASTREINTE, FORMATION, RTT, CONGE, MALADE, FERIE
En cas de conflit sur une date, les données existantes sont conservées et un avertissement est affiché.
"""
import csv
import sys
from datetime import date, time
from pathlib import Path
import sqlalchemy as sa
# Ajouter le dossier parent au path pour importer les modules
sys.path.insert(0, str(Path(__file__).parent.parent))
from app import create_app, db
from app.config_loader import day_types_without_journey, journey_has_motor
from app.models import TimeSlot, WorkEntry
DAY_TYPES = {"WORK", "TT", "GARDE", "ASTREINTE", "FORMATION", "RTT", "CONGE", "MALADE", "FERIE"}
def main(csv_path: str, config_path: str | None = None):
"""Importe les données depuis un fichier CSV vers la base de données."""
# Créer l'application Flask avec la config
app = create_app(config_path=config_path)
with app.app_context():
conflicts = []
imported_count = 0
# Lire le fichier CSV
with open(csv_path, "r", encoding="utf-8") as f:
csv_reader = csv.DictReader(f)
for row_num, row in enumerate(csv_reader, start=2):
try:
# Parser la date
entry_date = date.fromisoformat(row.get("date", "").strip())
except (ValueError, AttributeError):
conflicts.append(f"Ligne {row_num}: date invalide ou manquante")
continue
# Valider le type de jour
day_type = row.get("day_type", "WORK").strip().upper()
if day_type not in DAY_TYPES:
conflicts.append(f"Ligne {row_num}: type de jour invalide '{day_type}'")
continue
# Récupérer les autres champs
journey_profile_id = row.get("journey_profile_id", "").strip() or None
motor_vehicle_id = row.get("motor_vehicle_id", "").strip() or None
comment = row.get("comment", "").strip() or None
# Si le type de jour n'a pas de trajet, forcer journey_profile_id à None
if day_type in day_types_without_journey():
journey_profile_id = None
# Si le profil de trajet n'a pas de moteur, forcer motor_vehicle_id à None
if journey_profile_id and not journey_has_motor(journey_profile_id):
motor_vehicle_id = None
# Vérifier si une entrée existe déjà pour cette date
existing = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == entry_date)
)
if existing:
# Vérifier si les données sont différentes
is_different = (
existing.day_type != day_type
or existing.journey_profile_id != journey_profile_id
or existing.motor_vehicle_id != motor_vehicle_id
or existing.comment != comment
)
if is_different:
conflicts.append(
f"Ligne {row_num}: conflit sur la date {entry_date}. "
f"Données existantes conservées."
)
continue
# Créer la nouvelle entrée
entry = WorkEntry(
date=entry_date,
day_type=day_type,
journey_profile_id=journey_profile_id,
motor_vehicle_id=motor_vehicle_id,
comment=comment,
)
db.session.add(entry)
# Ajouter les plages horaires
start_times = row.get("start_time", "").split(";")
end_times = row.get("end_time", "").split(";")
for s, e in zip(start_times, end_times):
s = s.strip()
e = e.strip()
if s and e:
try:
db.session.add(
TimeSlot(
entry=entry,
start_time=time.fromisoformat(s),
end_time=time.fromisoformat(e),
)
)
except (ValueError, AttributeError):
conflicts.append(
f"Ligne {row_num}: format d'heure invalide '{s}' ou '{e}'"
)
db.session.rollback()
break
else:
imported_count += 1
continue
db.session.rollback()
break
# Valider et commiter
try:
db.session.commit()
except sa.exc.SQLAlchemyError as e:
db.session.rollback()
print(f"Erreur lors du commit: {e}", file=sys.stderr)
return 1
# Afficher les résultats
print(f"Import terminé: {imported_count} entrée(s) importée(s)")
if conflicts:
print(f"\n⚠️ {len(conflicts)} avertissement(s):")
for conflict in conflicts:
print(f" - {conflict}")
return 0
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Importer un fichier CSV dans la base de données")
parser.add_argument("csv_file", help="Chemin vers le fichier CSV à importer")
parser.add_argument(
"--config", default=None, help="Chemin vers le fichier config.toml (optionnel)"
)
args = parser.parse_args()
sys.exit(main(args.csv_file, args.config))

View File

@@ -1,11 +1,15 @@
import pytest
from app import create_app, db as _db
from app import create_app
from app import db as _db
from tests.in_memory_db import IN_MEMORY_DATABASE_URI, in_memory_engine_options
@pytest.fixture
def app(tmp_path):
config_path = tmp_path / "config.toml"
config_path.write_text("""
config_path.write_text(
"""
[vehicles.citadine]
name = "Citadine électrique"
fuel = "electric"
@@ -45,6 +49,12 @@ distances = { moteur = 14, velo = 8 }
name = "Vélo seul"
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]]
km_max = 3000
taux = 0.548
@@ -59,11 +69,16 @@ forfait = 699
km_max = 0
taux = 0.364
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["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with application.app_context():
_db.create_all()

20
tests/in_memory_db.py Normal file
View 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},
}

16
tests/test_app_factory.py Normal file
View File

@@ -0,0 +1,16 @@
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")
def test_sqlite_foreign_keys_are_enabled(app):
with app.app_context():
assert db.session.scalar(sa.text("PRAGMA foreign_keys")) == 1

View File

@@ -1,6 +1,43 @@
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):
with app.app_context():
from app.config_loader import get_vehicles
vehicles = get_vehicles()
assert "familiale" in vehicles
assert vehicles["familiale"]["co2_per_km"] == 142
@@ -9,6 +46,7 @@ def test_get_vehicles_returns_configured_vehicles(app):
def test_get_motor_vehicles_excludes_velo(app):
with app.app_context():
from app.config_loader import get_motor_vehicles
motor = get_motor_vehicles()
assert "familiale" in motor
assert "citadine" in motor
@@ -19,6 +57,7 @@ def test_get_motor_vehicles_excludes_velo(app):
def test_get_journeys_returns_profiles(app):
with app.app_context():
from app.config_loader import get_journeys
journeys = get_journeys()
assert "moteur_seul" in journeys
assert journeys["moteur_seul"]["distances"]["moteur"] == 25
@@ -27,6 +66,7 @@ def test_get_journeys_returns_profiles(app):
def test_journey_has_motor_true(app):
with app.app_context():
from app.config_loader import journey_has_motor
assert journey_has_motor("moteur_seul") is True
assert journey_has_motor("moteur_velo") is True
@@ -34,6 +74,7 @@ def test_journey_has_motor_true(app):
def test_journey_has_motor_false(app):
with app.app_context():
from app.config_loader import journey_has_motor
assert journey_has_motor("velo_seul") is False
assert journey_has_motor(None) is False
@@ -41,6 +82,7 @@ def test_journey_has_motor_false(app):
def test_get_bareme_returns_tranches(app):
with app.app_context():
from app.config_loader import get_bareme
tranches = get_bareme(2025, 5)
assert len(tranches) == 3
assert tranches[0]["taux"] == 0.548
@@ -49,6 +91,86 @@ def test_get_bareme_returns_tranches(app):
def test_day_types_without_journey(app):
with app.app_context():
from app.config_loader import day_types_without_journey
types = day_types_without_journey()
assert "TT" 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(),
)

View File

@@ -1,8 +1,8 @@
from app.business.leave_calc import compute_leave_used, get_or_create_balance
from app.models import WorkEntry, LeaveBalance
from app import db
from datetime import date
import sqlalchemy as sa
from app import db
from app.business.leave_calc import compute_leave_used, get_or_create_balance
from app.models import LeaveBalance, WorkEntry
def test_compute_leave_used_conges(app):

111
tests/test_models.py Normal file
View File

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

View File

@@ -1,8 +1,10 @@
from app.models import WorkEntry, TimeSlot
from app import db
from datetime import date, time
from datetime import date
import sqlalchemy as sa
from app import db
from app.models import WorkEntry
def test_dashboard_empty(client):
response = client.get("/")
@@ -17,21 +19,23 @@ def test_entry_form_get(client):
def test_create_entry(client, app):
response = client.post("/entries/new", data={
"date": "2025-06-02",
"day_type": "WORK",
"journey_profile_id": "moteur_seul",
"motor_vehicle_id": "familiale",
"start_time": ["09:00"],
"end_time": ["17:45"],
"comment": "",
}, follow_redirects=True)
response = client.post(
"/entries/new",
data={
"date": "2025-06-02",
"day_type": "WORK",
"journey_profile_id": "moteur_seul",
"motor_vehicle_id": "familiale",
"start_time": ["09:00"],
"end_time": ["17:45"],
"comment": "",
},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
entry = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 2))
)
entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 2)))
assert entry is not None
assert entry.day_type == "WORK"
assert len(entry.time_slots) == 1
@@ -66,29 +70,29 @@ def test_delete_entry(client, app):
assert response.status_code == 200
with app.app_context():
deleted = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.id == entry_id)
)
deleted = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.id == entry_id))
assert deleted is None
def test_create_entry_velo_no_motor_vehicle(client, app):
"""Un trajet vélo seul ne doit pas enregistrer de motor_vehicle_id."""
response = client.post("/entries/new", data={
"date": "2025-06-10",
"day_type": "WORK",
"journey_profile_id": "velo_seul",
"motor_vehicle_id": "",
"start_time": ["08:30"],
"end_time": ["17:00"],
"comment": "",
}, follow_redirects=True)
response = client.post(
"/entries/new",
data={
"date": "2025-06-10",
"day_type": "WORK",
"journey_profile_id": "velo_seul",
"motor_vehicle_id": "",
"start_time": ["08:30"],
"end_time": ["17:00"],
"comment": "",
},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
entry = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 10))
)
entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 10)))
assert entry is not None
assert entry.motor_vehicle_id is None

View File

@@ -1,8 +1,14 @@
from datetime import date
from datetime import time as dtime
from app.business.time_calc import (
count_day_types,
minutes_to_str,
work_minutes_reference,
monthly_stats,
week_balance_minutes,
work_minutes_reference,
)
from app.models import TimeSlot, WorkEntry
def test_minutes_to_str_basic():
@@ -42,12 +48,6 @@ def test_week_balance_negative():
assert week_balance_minutes(2200, 2325) == -125
from app.business.time_calc import count_day_types
from app.models import WorkEntry, TimeSlot
from app.business.time_calc import monthly_stats
from datetime import date, time as dtime
def test_count_day_types_basic():
entries = [
WorkEntry(date=date(2025, 1, 2), day_type="WORK"),
@@ -67,8 +67,10 @@ def _entry(d: date, *slots: tuple[str, str]) -> WorkEntry:
"""Helper : crée un WorkEntry avec des TimeSlots."""
entry = WorkEntry(date=d, day_type="WORK")
entry.time_slots = [
TimeSlot(start_time=dtime(*[int(x) for x in s.split(":")]),
end_time=dtime(*[int(x) for x in e.split(":")]))
TimeSlot(
start_time=dtime(*[int(x) for x in s.split(":")]),
end_time=dtime(*[int(x) for x in e.split(":")]),
)
for s, e in slots
]
return entry
@@ -89,9 +91,9 @@ def test_monthly_stats_empty():
def test_monthly_stats_median_daily_odd():
# 420, 465, 510 → médiane = 465
entries = [
_entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 min
_entry(date(2025, 1, 7), ("9:00", "16:45")), # 465 min
_entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min
_entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 min
_entry(date(2025, 1, 7), ("9:00", "16:45")), # 465 min
_entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min
]
result = monthly_stats(entries)
assert result["median_daily_min"] == 465
@@ -114,7 +116,7 @@ def test_monthly_stats_median_weekly():
entries = [
_entry(date(2025, 1, 6), ("9:00", "16:45")), # sem 2
_entry(date(2025, 1, 7), ("9:00", "16:45")), # sem 2
_entry(date(2025, 1, 13), ("9:00", "16:00")), # sem 3
_entry(date(2025, 1, 13), ("9:00", "16:00")), # sem 3
]
result = monthly_stats(entries)
assert result["median_weekly_min"] == 675

View File

@@ -1,7 +1,7 @@
from app.business.travel_calc import (
compute_km_for_entry,
compute_co2_grams,
compute_frais_reels,
compute_km_for_entry,
)
VEHICLES = {
@@ -17,15 +17,15 @@ JOURNEYS = {
}
TRANCHES_CV3 = [
{"km_max": 5000, "taux": 0.529, "forfait": 0},
{"km_max": 5000, "taux": 0.529, "forfait": 0},
{"km_max": 20000, "taux": 0.316, "forfait": 1065},
{"km_max": 0, "taux": 0.370, "forfait": 0},
{"km_max": 0, "taux": 0.370, "forfait": 0},
]
TRANCHES_CV5 = [
{"km_max": 5000, "taux": 0.636, "forfait": 0},
{"km_max": 5000, "taux": 0.636, "forfait": 0},
{"km_max": 20000, "taux": 0.357, "forfait": 1395},
{"km_max": 0, "taux": 0.427, "forfait": 0},
{"km_max": 0, "taux": 0.427, "forfait": 0},
]