2 Commits

30 changed files with 140 additions and 1988 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,8 +1,6 @@
# AGENTS.md # CLAUDE.md
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. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Pour une présentation complète de l'architecture, de la configuration et du guide de démarrage, se référer à [docs/onboarding.md](docs/onboarding.md).
## Commands ## Commands
@@ -27,28 +25,14 @@ python -m venv .venv
sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY
sudo systemctl restart tableau-de-bord-pro sudo systemctl restart tableau-de-bord-pro
# Qualité du code (Ruff) # Git commit (GPG signing désactivé — pinentry inaccessible dans cet env)
.venv/bin/ruff check . git -c commit.gpgsign=false commit -m "..."
.venv/bin/ruff format --check .
.venv/bin/ruff format .
# Découverte des modèles OpenCode (configuration environnementale hors dépôt)
grep -A 1 -B 1 subagent /home/antoine/.config/opencode/opencode.json
``` ```
## Variables d'environnement ## Variables d'environnement
- `SECRET_KEY` : requis en production (défaut `dev-secret-change-in-prod` en dev) - `SECRET_KEY` : requis en production (défaut `dev-secret-change-in-prod` en dev)
## Git & Conventions de Commit
- **Politique Git** : Les commits intermédiaires générés par les agents d'IA doivent être non signés en utilisant l'option `git -c commit.gpgsign=false commit -m "..."` (car pinentry est inaccessible dans cet environnement). L'utilisateur effectuera un amend signé (`git commit --amend -S`) du commit final lorsqu'il sera disponible.
- **Convention de trailers** : Chaque commit réalisé par un agent d'IA doit inclure le trailer suivant à la fin du message de commit pour identifier le modèle utilisé :
```text
Co-authored-by: Fournisseur/Modèle <vibecoder@antoineve.me>
```
*Exemple :* `Co-authored-by: Anthropic/Claude-3.5-Sonnet <vibecoder@antoineve.me>` ou `Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>`.
## Architecture ## Architecture
Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The DB is SQLite via SQLAlchemy, stored in `instance/worklog.db`. All vehicle/journey/tax configuration lives in `config.toml` (loaded at startup into `app.config["TOML"]`), not in the database. Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The DB is SQLite via SQLAlchemy, stored in `instance/worklog.db`. All vehicle/journey/tax configuration lives in `config.toml` (loaded at startup into `app.config["TOML"]`), not in the database.
@@ -78,8 +62,7 @@ Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The D
- **Pas de migration de schéma** : l'app utilise `db.create_all()` uniquement (pas d'Alembic). Tout changement de modèle nécessite de supprimer `instance/worklog.db` en dev, ou une migration manuelle en prod. - **Pas de migration de schéma** : l'app utilise `db.create_all()` uniquement (pas d'Alembic). Tout changement de modèle nécessite de supprimer `instance/worklog.db` en dev, ou une migration manuelle en prod.
- **Barème kilométrique** : les tranches dans `config.toml` sont à mettre à jour manuellement chaque année (section `[bareme_kilometrique.YYYY]`). - **Barème kilométrique** : les tranches dans `config.toml` sont à mettre à jour manuellement chaque année (section `[bareme_kilometrique.YYYY]`).
- **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 ». - **`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.
- **Calculs de durée métier et transitions DST** : Le calcul de la durée des plages horaires de travail (`TimeSlot` via `total_minutes()`) est totalement indépendant des métadonnées et repose sur des heures murales (locales). Une plage horaire traversant un changement d'heure saisonnier (passage heure d'été/hiver / DST) soulève un enjeu métier spécifique (gestion des durées d'heures locales) qui nécessiterait, le cas échéant, une représentation dédiée ou une politique métier spécifique.
- **Filtres Jinja2** (définis dans `app/__init__.py`) : `{{ date | date_fr }}` pour les dates en français ; `{{ day_type | day_type_fr }}` pour les libellés de types de jours (WORK→Travail, TT→Télétravail, etc.). - **Filtres Jinja2** (définis dans `app/__init__.py`) : `{{ date | date_fr }}` pour les dates en français ; `{{ day_type | day_type_fr }}` pour les libellés de types de jours (WORK→Travail, TT→Télétravail, etc.).
- **`db.get_engine()` deprecated** en Flask-SQLAlchemy 3.x → utiliser `db.engine`. - **`db.get_engine()` deprecated** en Flask-SQLAlchemy 3.x → utiliser `db.engine`.
- **Migration `_migrate_db`** : vérifier l'existence de la table avant `ALTER TABLE` — SQLite peut avoir un fichier DB sans tables (ex: premier démarrage avec `instance/worklog.db` vide). - **Migration `_migrate_db`** : vérifier l'existence de la table avant `ALTER TABLE` — SQLite peut avoir un fichier DB sans tables (ex: premier démarrage avec `instance/worklog.db` vide).

View File

@@ -74,40 +74,6 @@ Toute la configuration métier se trouve dans `config.toml` :
.venv/bin/python -m pytest .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 ## Licence
[MIT](LICENSE.md) — Copyright (c) 2026 Antoine Van-Elstraete [MIT](LICENSE.md) — Copyright (c) 2026 Antoine Van-Elstraete

View File

@@ -1,46 +1,15 @@
"""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 import Flask
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
import tomllib
import os
import sqlalchemy as sa
db = SQLAlchemy() db = SQLAlchemy()
def _migrate_db(app): def _migrate_db(app):
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic). """Applique les migrations de schéma manquantes (pas d'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 import sqlite3
with app.app_context():
if db.engine.url.database in (None, ":memory:"):
return # Une base mémoire est initialisée par create_all().
db_path = os.path.join(app.instance_path, "worklog.db") db_path = os.path.join(app.instance_path, "worklog.db")
if not os.path.exists(db_path): if not os.path.exists(db_path):
return # Nouvelle DB, create_all() s'en charge return # Nouvelle DB, create_all() s'en charge
@@ -56,114 +25,48 @@ def _migrate_db(app):
engine = db.engine engine = db.engine
if "motor_vehicle_id" not in columns: if "motor_vehicle_id" not in columns:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute( conn.execute(sa.text(
sa.text("ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)") "ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)"
) ))
conn.commit() conn.commit()
_JOURS_FR = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"] _JOURS_FR = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
_MOIS_FR = [ _MOIS_FR = ["", "janvier", "février", "mars", "avril", "mai", "juin",
"", "juillet", "août", "septembre", "octobre", "novembre", "décembre"]
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
]
_DAY_TYPE_LABELS = { _DAY_TYPE_LABELS = {
"WORK": "Travail", "WORK": "Travail",
"TT": "Télétravail", "TT": "Télétravail",
"GARDE": "Garde", "GARDE": "Garde",
"ASTREINTE": "Astreinte", "ASTREINTE": "Astreinte",
"FORMATION": "Formation", "FORMATION": "Formation",
"RTT": "RTT", "RTT": "RTT",
"CONGE": "Congé", "CONGE": "Congé",
"MALADE": "Maladie", "MALADE": "Maladie",
"FERIE": "Férié", "FERIE": "Férié",
} }
def _day_type_fr(code): 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) return _DAY_TYPE_LABELS.get(code, code)
def _date_fr(d): def _date_fr(d):
"""Filtre Jinja2 pour formater une date en français lisible. """Formate une date en français : 'mercredi 11 mars 2026'."""
from datetime import date as date_type
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()] jour = _JOURS_FR[d.weekday()]
mois = _MOIS_FR[d.month] mois = _MOIS_FR[d.month]
return f"{jour} {d.day} {mois} {d.year}" return f"{jour} {d.day} {mois} {d.year}"
def create_app( def create_app(config_path=None):
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) app = Flask(__name__, instance_relative_config=True)
os.makedirs(app.instance_path, exist_ok=True) os.makedirs(app.instance_path, exist_ok=True)
if database_uri is None: app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
database_uri = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
if engine_options is not None:
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = engine_options
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod") app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
@@ -176,11 +79,6 @@ def create_app(
else: else:
app.config["TOML"] = {} app.config["TOML"] = {}
from app.config_loader import get_home_assistant_config
with app.app_context():
app.config["HOME_ASSISTANT"] = get_home_assistant_config()
db.init_app(app) db.init_app(app)
app.jinja_env.filters["date_fr"] = _date_fr app.jinja_env.filters["date_fr"] = _date_fr
app.jinja_env.filters["day_type_fr"] = _day_type_fr app.jinja_env.filters["day_type_fr"] = _day_type_fr
@@ -188,7 +86,6 @@ def create_app(
from app.routes.dashboard import bp as dashboard_bp from app.routes.dashboard import bp as dashboard_bp
from app.routes.entries import bp as entries_bp from app.routes.entries import bp as entries_bp
from app.routes.reports import bp as reports_bp from app.routes.reports import bp as reports_bp
app.register_blueprint(dashboard_bp) app.register_blueprint(dashboard_bp)
app.register_blueprint(entries_bp) app.register_blueprint(entries_bp)
app.register_blueprint(reports_bp) app.register_blueprint(reports_bp)

View File

@@ -1,75 +1,34 @@
from datetime import date
import sqlalchemy as sa
from app import db from app import db
from app.models import LeaveBalance, WorkEntry from app.models import WorkEntry, LeaveBalance
import sqlalchemy as sa
from datetime import date
def compute_leave_used(year: int) -> dict[str, int]: 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) start = date(year, 1, 1)
end = date(year, 12, 31) end = date(year, 12, 31)
conges = ( conges = db.session.scalar(
db.session.scalar( sa.select(sa.func.count()).where(
sa.select(sa.func.count()).where( WorkEntry.date.between(start, end),
WorkEntry.date.between(start, end), WorkEntry.day_type == "CONGE",
WorkEntry.day_type == "CONGE",
)
) )
or 0 ) or 0
)
rtt = ( rtt = db.session.scalar(
db.session.scalar( sa.select(sa.func.count()).where(
sa.select(sa.func.count()).where( WorkEntry.date.between(start, end),
WorkEntry.date.between(start, end), WorkEntry.day_type == "RTT",
WorkEntry.day_type == "RTT",
)
) )
or 0 ) or 0
)
return {"conges": conges, "rtt": rtt} return {"conges": conges, "rtt": rtt}
def get_or_create_balance(year: int) -> LeaveBalance: def get_or_create_balance(year: int) -> LeaveBalance:
""" balance = db.session.scalar(
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. sa.select(LeaveBalance).where(LeaveBalance.year == year)
)
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: if balance is None:
balance = LeaveBalance(year=year) balance = LeaveBalance(year=year)
db.session.add(balance) db.session.add(balance)

View File

@@ -1,16 +1,4 @@
import statistics as _stats
def minutes_to_str(minutes: int) -> str: 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 "" sign = "-" if minutes < 0 else ""
minutes = abs(minutes) minutes = abs(minutes)
return f"{sign}{minutes // 60}h{minutes % 60:02d}" return f"{sign}{minutes // 60}h{minutes % 60:02d}"
@@ -30,79 +18,15 @@ _REFERENCE_MINUTES = {
def work_minutes_reference(day_type: str) -> int: 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) return _REFERENCE_MINUTES.get(day_type, 465)
def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int: 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 return actual_minutes - reference_minutes
def monthly_stats(entries: list) -> dict:
"""
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}
daily = [e.total_minutes() for e in entries]
median_daily = int(_stats.median(daily))
weekly: dict[tuple, int] = {}
for e in entries:
key = e.date.isocalendar()[:2] # (year, isoweek)
weekly[key] = weekly.get(key, 0) + e.total_minutes()
median_weekly = int(_stats.median(weekly.values()))
return {"median_daily_min": median_daily, "median_weekly_min": median_weekly}
def count_day_types(entries: list) -> dict[str, int]: 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] = {} counts: dict[str, int] = {}
for entry in entries: for entry in entries:
counts[entry.day_type] = counts.get(entry.day_type, 0) + 1 counts[entry.day_type] = counts.get(entry.day_type, 0) + 1

View File

@@ -4,22 +4,9 @@ def compute_km_for_entry(
motor_vehicle_id: str | None = None, motor_vehicle_id: str | None = None,
) -> dict[str, int]: ) -> dict[str, int]:
""" """
Calcule les distances parcourues par véhicule pour une entrée de journal donnée. 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.
Cette fonction associe un profil de trajet à ses distances configurées. Si le profil Retourne {} si pas de profil (TT, CONGE, etc.).
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: if not journey_profile_id:
return {} return {}
@@ -36,18 +23,7 @@ def compute_km_for_entry(
def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float: 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 total = 0.0
for vehicle_id, km in km_by_vehicle.items(): for vehicle_id, km in km_by_vehicle.items():
vehicle = vehicles.get(vehicle_id, {}) vehicle = vehicles.get(vehicle_id, {})
@@ -56,26 +32,11 @@ def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float:
return total return total
def compute_frais_reels( def compute_frais_reels(total_km_moteur: float, tranches: list[dict], electric: bool = False) -> float:
total_km_moteur: float, tranches: list[dict], electric: bool = False
) -> float:
""" """
Calcule le montant des frais réels déductibles selon le barème kilométrique fiscal. Calcule les frais réels fiscaux selon le barème kilométrique.
km_max = 0 signifie "pas de limite" (dernière tranche).
Le calcul s'effectue tranche par tranche en fonction du kilométrage annuel total parcouru electric=True applique la majoration de 20 % pour véhicules électriques.
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: if not tranches or total_km_moteur <= 0:
return 0.0 return 0.0

View File

@@ -1,156 +1,21 @@
"""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 from flask import current_app
_HOME_ASSISTANT_REQUIRED_KEYS = {
"timezone",
"default_day_type",
"default_journey_profile_id",
"default_motor_vehicle_id",
}
_VALID_DAY_TYPES = {
"WORK",
"TT",
"GARDE",
"ASTREINTE",
"FORMATION",
"RTT",
"CONGE",
"MALADE",
"FERIE",
}
def get_vehicles(): def get_vehicles():
"""Récupère l'ensemble des véhicules configurés dans le fichier TOML.
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", {}) return current_app.config.get("TOML", {}).get("vehicles", {})
def get_motor_vehicles(): def get_motor_vehicles():
"""Filtre et retourne uniquement les véhicules de type 'moteur'. """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"} return {k: v for k, v in get_vehicles().items() if v.get("type") == "moteur"}
def get_journeys(): 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", {}) return current_app.config.get("TOML", {}).get("journeys", {})
def get_home_assistant_config() -> dict[str, str] | None:
"""Retourne la configuration Home Assistant, après validation.
L'absence de section désactive la future intégration et reste compatible avec
les anciens fichiers TOML. Une section présente doit en revanche être
complète et cohérente avec les véhicules, trajets et types de journées
connus de l'application.
"""
config = current_app.config.get("TOML", {}).get("home_assistant")
if config is None:
return None
if not isinstance(config, dict):
raise ValueError("Configuration [home_assistant] invalide : la section doit être une table")
missing = _HOME_ASSISTANT_REQUIRED_KEYS - config.keys()
if missing:
missing_keys = ", ".join(sorted(missing))
raise ValueError(
f"Configuration [home_assistant] incomplète : clé(s) manquante(s) {missing_keys}"
)
if any(
not isinstance(config[key], str) or not config[key] for key in _HOME_ASSISTANT_REQUIRED_KEYS
):
raise ValueError(
"Configuration [home_assistant] invalide : toutes les valeurs doivent être des chaînes non vides"
)
timezone = config["timezone"]
try:
ZoneInfo(timezone)
except (ZoneInfoNotFoundError, ValueError) as exc:
raise ValueError(
f"Configuration [home_assistant] invalide : fuseau horaire inconnu {timezone!r}"
) from exc
day_type = config["default_day_type"]
if day_type not in _VALID_DAY_TYPES:
raise ValueError(
f"Configuration [home_assistant] invalide : type de journée inconnu {day_type!r}"
)
journey_id = config["default_journey_profile_id"]
if journey_id not in get_journeys():
raise ValueError(f"Configuration [home_assistant] invalide : trajet inconnu {journey_id!r}")
vehicle_id = config["default_motor_vehicle_id"]
vehicle = get_vehicles().get(vehicle_id)
if vehicle is None:
raise ValueError(
f"Configuration [home_assistant] invalide : véhicule inconnu {vehicle_id!r}"
)
if vehicle.get("type") != "moteur":
raise ValueError(
f"Configuration [home_assistant] invalide : le véhicule {vehicle_id!r} n'est pas un véhicule moteur"
)
return {key: config[key] for key in _HOME_ASSISTANT_REQUIRED_KEYS}
def journey_has_motor(journey_profile_id: str | None) -> bool: def journey_has_motor(journey_profile_id: str | None) -> bool:
"""Vérifie si un profil de trajet donné inclut une distance pour véhicule à moteur. """Retourne True si le profil de trajet inclut un 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: if not journey_profile_id:
return False return False
journeys = get_journeys() journeys = get_journeys()
@@ -159,24 +24,6 @@ def journey_has_motor(journey_profile_id: str | None) -> bool:
def get_bareme(year: int, cv: int) -> list[dict]: 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", {}) bareme = current_app.config.get("TOML", {}).get("bareme_kilometrique", {})
year_data = bareme.get(str(year), {}) year_data = bareme.get(str(year), {})
if cv <= 3: if cv <= 3:
@@ -193,12 +40,4 @@ def get_bareme(year: int, cv: int) -> list[dict]:
def day_types_without_journey(): 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"} return {"TT", "MALADE", "CONGE", "RTT", "FERIE"}

View File

@@ -1,24 +1,10 @@
from datetime import UTC, date, datetime, time from app import db
from datetime import date, time, datetime
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as so import sqlalchemy.orm as so
from app import db
class WorkEntry(db.Model): 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" __tablename__ = "work_entries"
id: so.Mapped[int] = so.mapped_column(primary_key=True) id: so.Mapped[int] = so.mapped_column(primary_key=True)
@@ -27,11 +13,9 @@ class WorkEntry(db.Model):
motor_vehicle_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True) motor_vehicle_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK") day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True) comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
created_at: so.Mapped[datetime] = so.mapped_column( created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=datetime.utcnow)
sa.DateTime, default=lambda: datetime.now(UTC)
)
updated_at: so.Mapped[datetime] = so.mapped_column( updated_at: so.Mapped[datetime] = so.mapped_column(
sa.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC) sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
) )
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship( time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
@@ -39,17 +23,6 @@ class WorkEntry(db.Model):
) )
def total_minutes(self) -> int: 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 total = 0
for slot in self.time_slots: for slot in self.time_slots:
start = slot.start_time.hour * 60 + slot.start_time.minute start = slot.start_time.hour * 60 + slot.start_time.minute
@@ -60,24 +33,11 @@ class WorkEntry(db.Model):
return total return total
def total_hours_str(self) -> str: 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() minutes = self.total_minutes()
return f"{minutes // 60}h{minutes % 60:02d}" return f"{minutes // 60}h{minutes % 60:02d}"
class TimeSlot(db.Model): 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" __tablename__ = "time_slots"
id: so.Mapped[int] = so.mapped_column(primary_key=True) id: so.Mapped[int] = so.mapped_column(primary_key=True)
@@ -89,13 +49,6 @@ class TimeSlot(db.Model):
class LeaveBalance(db.Model): 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" __tablename__ = "leave_balance"
id: so.Mapped[int] = so.mapped_column(primary_key=True) id: so.Mapped[int] = so.mapped_column(primary_key=True)

View File

@@ -1,7 +0,0 @@
"""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,48 +1,18 @@
"""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 flask import Blueprint, render_template
from datetime import date, timedelta
import sqlalchemy as sa
from app import db from app import db
from app.business.leave_calc import compute_leave_used, get_or_create_balance
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 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
bp = Blueprint("dashboard", __name__) bp = Blueprint("dashboard", __name__)
@bp.route("/") @bp.route("/")
def index(): 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() today = date.today()
year = today.year year = today.year
@@ -75,7 +45,9 @@ def index():
balance = get_or_create_balance(year) balance = get_or_create_balance(year)
used = compute_leave_used(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( return render_template(
"dashboard.html", "dashboard.html",

View File

@@ -1,34 +1,9 @@
"""Blueprint des routes de gestion des entrées de temps (WorkEntry). from flask import Blueprint, render_template, request, redirect, url_for, flash
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 from datetime import date, time
import sqlalchemy as sa import sqlalchemy as sa
from flask import Blueprint, flash, redirect, render_template, request, url_for
from app import db from app import db
from app.config_loader import ( from app.models import WorkEntry, TimeSlot
day_types_without_journey, from app.config_loader import get_journeys, get_motor_vehicles, day_types_without_journey, journey_has_motor
get_journeys,
get_motor_vehicles,
journey_has_motor,
)
from app.models import TimeSlot, WorkEntry
bp = Blueprint("entries", __name__, url_prefix="/entries") bp = Blueprint("entries", __name__, url_prefix="/entries")
@@ -47,55 +22,15 @@ DAY_TYPES = [
@bp.route("/") @bp.route("/")
def list_entries(): def list_entries():
"""Affiche la liste historique de toutes les entrées de temps enregistrées. entries = db.session.scalars(
sa.select(WorkEntry).order_by(WorkEntry.date.desc())
Cette route récupère l'ensemble des entrées (`WorkEntry`) triées par date décroissante ).all()
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) return render_template("entry_list.html", entries=entries)
@bp.route("/new", methods=["GET", "POST"]) @bp.route("/new", methods=["GET", "POST"])
@bp.route("/<int:entry_id>/edit", methods=["GET", "POST"]) @bp.route("/<int:entry_id>/edit", methods=["GET", "POST"])
def entry_form(entry_id=None): 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 entry = None
if entry_id: if entry_id:
entry = db.session.get(WorkEntry, entry_id) entry = db.session.get(WorkEntry, entry_id)
@@ -116,7 +51,9 @@ def entry_form(entry_id=None):
journey_profile_id = None journey_profile_id = None
if entry is 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: if existing:
flash(f"Une entrée existe déjà pour le {entry_date}.", "error") flash(f"Une entrée existe déjà pour le {entry_date}.", "error")
return redirect(url_for("entries.entry_form")) return redirect(url_for("entries.entry_form"))
@@ -135,13 +72,11 @@ def entry_form(entry_id=None):
ends = request.form.getlist("end_time") ends = request.form.getlist("end_time")
for s, e in zip(starts, ends): for s, e in zip(starts, ends):
if s and e: if s and e:
db.session.add( db.session.add(TimeSlot(
TimeSlot( entry=entry,
entry=entry, start_time=time.fromisoformat(s),
start_time=time.fromisoformat(s), end_time=time.fromisoformat(e),
end_time=time.fromisoformat(e), ))
)
)
db.session.commit() db.session.commit()
flash("Entrée enregistrée.", "success") flash("Entrée enregistrée.", "success")
@@ -161,21 +96,6 @@ def entry_form(entry_id=None):
@bp.route("/<int:entry_id>/delete", methods=["POST"]) @bp.route("/<int:entry_id>/delete", methods=["POST"])
def delete_entry(entry_id): 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) entry = db.session.get(WorkEntry, entry_id)
if entry: if entry:
db.session.delete(entry) db.session.delete(entry)

View File

@@ -1,71 +1,17 @@
"""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 flask import Blueprint, render_template, request
from datetime import date
import sqlalchemy as sa
from app import db 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.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
from app.config_loader import get_vehicles, get_journeys, get_bareme
bp = Blueprint("reports", __name__, url_prefix="/reports") 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",
}
@bp.route("/") @bp.route("/")
def index(): 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) year = request.args.get("year", date.today().year, type=int)
start = date(year, 1, 1) start = date(year, 1, 1)
end = date(year, 12, 31) end = date(year, 12, 31)
@@ -77,7 +23,6 @@ def index():
vehicles = get_vehicles() vehicles = get_vehicles()
journeys = get_journeys() journeys = get_journeys()
# --- Stats annuelles (inchangées) ---
total_km = {} total_km = {}
total_co2 = 0.0 total_co2 = 0.0
for entry in entries: for entry in entries:
@@ -97,34 +42,6 @@ def index():
day_type_counts = count_day_types(entries) day_type_counts = count_day_types(entries)
# --- Stats mensuelles ---
entries_by_month: dict[int, list] = defaultdict(list)
for entry in entries:
entries_by_month[entry.date.month].append(entry)
monthly_data = {}
for month in range(1, 13):
month_entries = entries_by_month.get(month, [])
month_km: dict[str, int] = {}
for entry in month_entries:
km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id)
for v, d in km.items():
month_km[v] = month_km.get(v, 0) + d
stats = monthly_stats(month_entries)
monthly_data[month] = {
"month_name": MONTHS_FR[month],
"entry_count": len(month_entries),
"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 "",
}
return render_template( return render_template(
"reports.html", "reports.html",
year=year, year=year,
@@ -133,5 +50,4 @@ def index():
frais_reels=frais_reels, frais_reels=frais_reels,
vehicles=vehicles, vehicles=vehicles,
day_type_counts=day_type_counts, day_type_counts=day_type_counts,
monthly_data=monthly_data,
) )

View File

@@ -78,72 +78,4 @@
{% endif %} {% endif %}
</div> </div>
<!-- Détail mensuel -->
<p class="font-display text-lg font-semibold mt-8 mb-3" style="color:var(--ink);">Détail mensuel</p>
{% for month_num in range(1, 13) %}
{% set m = monthly_data[month_num] %}
<div class="card card-ink mb-4">
<div class="flex items-baseline justify-between mb-2">
<p class="card-label">{{ m.month_name }}</p>
<span class="text-xs" style="color:#9A9288;">{{ m.entry_count }} j</span>
</div>
{% if m.entry_count == 0 %}
<p class="text-sm" style="color:#9A9288;">Aucune entrée</p>
{% else %}
{# --- Barre transport --- #}
{% if m.km_total > 0 %}
<p class="font-data font-semibold mb-1" style="font-size:1.2rem; color:var(--ink);">
{{ m.km_total }}<span class="font-normal text-xs ml-1" style="color:#9A9288;">km</span>
</p>
<div class="flex rounded overflow-hidden mb-1" style="height:10px; background:#e5e2dc;">
{% for vehicle_id, km in m.km_by_vehicle.items() %}
{% set v_info = vehicles.get(vehicle_id, {}) %}
{% if v_info.get('type') == 'velo' %}
{% set color = '#4ade80' %}
{% elif v_info.get('fuel') == 'electric' %}
{% set color = '#818cf8' %}
{% else %}
{% set color = '#d4a574' %}
{% endif %}
{% set pct = (km / m.km_total * 100) | round(1) %}
<div style="width:{{ pct }}%; background-color:{{ color }};"></div>
{% endfor %}
</div>
<div class="flex flex-wrap gap-x-3 mb-3">
{% for vehicle_id, km in m.km_by_vehicle.items() %}
{% set v_info = vehicles.get(vehicle_id, {}) %}
{% if v_info.get('type') == 'velo' %}
{% set color = '#4ade80' %}
{% elif v_info.get('fuel') == 'electric' %}
{% set color = '#818cf8' %}
{% else %}
{% set color = '#d4a574' %}
{% endif %}
<span class="text-xs" style="color:#8A8278;">
<span style="display:inline-block; width:8px; height:8px; border-radius:2px; background:{{ color }}; margin-right:3px;"></span>
{{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} ({{ km }} km)
</span>
{% endfor %}
</div>
{% else %}
<p class="text-xs mb-3" style="color:#9A9288;">Aucun déplacement</p>
{% endif %}
{# --- Stats temporelles --- #}
<div class="flex gap-6">
<div>
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / jour</p>
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_daily_str }}</p>
</div>
<div>
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / semaine</p>
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_weekly_str }}</p>
</div>
</div>
{% endif %}
</div>
{% endfor %}
{% endblock %} {% endblock %}

View File

@@ -37,12 +37,6 @@ distances = { moteur = 14, velo = 8 }
name = "Vélo seul" name = "Vélo seul"
distances = { velo = 24 } distances = { velo = 24 }
[home_assistant]
timezone = "Europe/Paris"
default_day_type = "WORK"
default_journey_profile_id = "moteur_seul"
default_motor_vehicle_id = "citadine"
# --- Barème kilométrique voitures 2025 (revenus 2024) --- # --- Barème kilométrique voitures 2025 (revenus 2024) ---
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686 # Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py # Majoration +20% pour véhicules électriques gérée dans travel_calc.py
@@ -121,82 +115,3 @@ forfait = 1515
km_max = 0 km_max = 0
taux = 0.470 taux = 0.470
forfait = 0 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

View File

@@ -1,287 +0,0 @@
# 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).
---
## 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() conn.close()
with app.app_context(): with app.app_context():
engine = db.engine engine = db.get_engine()
if "motor_vehicle_id" not in columns: if "motor_vehicle_id" not in columns:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(sa.text( conn.execute(sa.text(

View File

@@ -384,7 +384,7 @@ git commit -m "feat: TOML config loader for vehicles, journeys, and tax scales"
```python ```python
from app import db from app import db
from datetime import date, time, datetime, UTC from datetime import date, time, datetime
from enum import Enum as PyEnum from enum import Enum as PyEnum
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as so import sqlalchemy.orm as so
@@ -410,9 +410,9 @@ class WorkEntry(db.Model):
journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True) journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK") day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True) comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=lambda: datetime.now(UTC)) created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=datetime.utcnow)
updated_at: so.Mapped[datetime] = so.mapped_column( updated_at: so.Mapped[datetime] = so.mapped_column(
sa.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC) sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
) )
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship( time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(

View File

@@ -1,266 +0,0 @@
# 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.

View File

@@ -1,16 +0,0 @@
[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,4 +3,3 @@ flask-sqlalchemy>=3.1
pytest>=8.0 pytest>=8.0
pytest-flask>=1.3 pytest-flask>=1.3
gunicorn>=22.0 gunicorn>=22.0
ruff>=0.9,<1.0

View File

@@ -1,166 +0,0 @@
#!/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,15 +1,11 @@
import pytest 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 @pytest.fixture
def app(tmp_path): def app(tmp_path):
config_path = tmp_path / "config.toml" config_path = tmp_path / "config.toml"
config_path.write_text( config_path.write_text("""
"""
[vehicles.citadine] [vehicles.citadine]
name = "Citadine électrique" name = "Citadine électrique"
fuel = "electric" fuel = "electric"
@@ -49,12 +45,6 @@ distances = { moteur = 14, velo = 8 }
name = "Vélo seul" name = "Vélo seul"
distances = { velo = 24 } distances = { velo = 24 }
[home_assistant]
timezone = "Europe/Paris"
default_day_type = "WORK"
default_journey_profile_id = "moteur_seul"
default_motor_vehicle_id = "citadine"
[[bareme_kilometrique.2025.cv_5.tranches]] [[bareme_kilometrique.2025.cv_5.tranches]]
km_max = 3000 km_max = 3000
taux = 0.548 taux = 0.548
@@ -69,16 +59,11 @@ forfait = 699
km_max = 0 km_max = 0
taux = 0.364 taux = 0.364
forfait = 0 forfait = 0
""", """, encoding="utf-8")
encoding="utf-8",
)
application = create_app( application = create_app(config_path=str(config_path))
config_path=str(config_path),
database_uri=IN_MEMORY_DATABASE_URI,
engine_options=in_memory_engine_options(),
)
application.config["TESTING"] = True application.config["TESTING"] = True
application.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
with application.app_context(): with application.app_context():
_db.create_all() _db.create_all()

View File

@@ -1,20 +0,0 @@
"""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},
}

View File

@@ -1,11 +0,0 @@
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")

View File

@@ -1,43 +1,6 @@
import pytest
from app import create_app
from tests.in_memory_db import IN_MEMORY_DATABASE_URI, in_memory_engine_options
_MINIMAL_CONFIG = """
[vehicles.citadine]
name = "Citadine"
type = "moteur"
[vehicles.velo]
name = "Vélo"
type = "velo"
[journeys.moteur_seul]
name = "Moteur seul"
distances = {{ moteur = 1 }}
[home_assistant]
timezone = "{timezone}"
default_day_type = "{day_type}"
default_journey_profile_id = "{journey_id}"
default_motor_vehicle_id = "{vehicle_id}"
"""
def _create_app_with_home_assistant_config(tmp_path, **values):
config_path = tmp_path / "config.toml"
config_path.write_text(_MINIMAL_CONFIG.format(**values), encoding="utf-8")
return create_app(
config_path=str(config_path),
database_uri=IN_MEMORY_DATABASE_URI,
engine_options=in_memory_engine_options(),
)
def test_get_vehicles_returns_configured_vehicles(app): def test_get_vehicles_returns_configured_vehicles(app):
with app.app_context(): with app.app_context():
from app.config_loader import get_vehicles from app.config_loader import get_vehicles
vehicles = get_vehicles() vehicles = get_vehicles()
assert "familiale" in vehicles assert "familiale" in vehicles
assert vehicles["familiale"]["co2_per_km"] == 142 assert vehicles["familiale"]["co2_per_km"] == 142
@@ -46,7 +9,6 @@ def test_get_vehicles_returns_configured_vehicles(app):
def test_get_motor_vehicles_excludes_velo(app): def test_get_motor_vehicles_excludes_velo(app):
with app.app_context(): with app.app_context():
from app.config_loader import get_motor_vehicles from app.config_loader import get_motor_vehicles
motor = get_motor_vehicles() motor = get_motor_vehicles()
assert "familiale" in motor assert "familiale" in motor
assert "citadine" in motor assert "citadine" in motor
@@ -57,7 +19,6 @@ def test_get_motor_vehicles_excludes_velo(app):
def test_get_journeys_returns_profiles(app): def test_get_journeys_returns_profiles(app):
with app.app_context(): with app.app_context():
from app.config_loader import get_journeys from app.config_loader import get_journeys
journeys = get_journeys() journeys = get_journeys()
assert "moteur_seul" in journeys assert "moteur_seul" in journeys
assert journeys["moteur_seul"]["distances"]["moteur"] == 25 assert journeys["moteur_seul"]["distances"]["moteur"] == 25
@@ -66,7 +27,6 @@ def test_get_journeys_returns_profiles(app):
def test_journey_has_motor_true(app): def test_journey_has_motor_true(app):
with app.app_context(): with app.app_context():
from app.config_loader import journey_has_motor from app.config_loader import journey_has_motor
assert journey_has_motor("moteur_seul") is True assert journey_has_motor("moteur_seul") is True
assert journey_has_motor("moteur_velo") is True assert journey_has_motor("moteur_velo") is True
@@ -74,7 +34,6 @@ def test_journey_has_motor_true(app):
def test_journey_has_motor_false(app): def test_journey_has_motor_false(app):
with app.app_context(): with app.app_context():
from app.config_loader import journey_has_motor from app.config_loader import journey_has_motor
assert journey_has_motor("velo_seul") is False assert journey_has_motor("velo_seul") is False
assert journey_has_motor(None) is False assert journey_has_motor(None) is False
@@ -82,7 +41,6 @@ def test_journey_has_motor_false(app):
def test_get_bareme_returns_tranches(app): def test_get_bareme_returns_tranches(app):
with app.app_context(): with app.app_context():
from app.config_loader import get_bareme from app.config_loader import get_bareme
tranches = get_bareme(2025, 5) tranches = get_bareme(2025, 5)
assert len(tranches) == 3 assert len(tranches) == 3
assert tranches[0]["taux"] == 0.548 assert tranches[0]["taux"] == 0.548
@@ -91,86 +49,6 @@ def test_get_bareme_returns_tranches(app):
def test_day_types_without_journey(app): def test_day_types_without_journey(app):
with app.app_context(): with app.app_context():
from app.config_loader import day_types_without_journey from app.config_loader import day_types_without_journey
types = day_types_without_journey() types = day_types_without_journey()
assert "TT" in types assert "TT" in types
assert "WORK" not in types assert "WORK" not in types
def test_get_home_assistant_config_returns_validated_defaults(app):
with app.app_context():
from app.config_loader import get_home_assistant_config
assert get_home_assistant_config() == {
"timezone": "Europe/Paris",
"default_day_type": "WORK",
"default_journey_profile_id": "moteur_seul",
"default_motor_vehicle_id": "citadine",
}
assert app.config["HOME_ASSISTANT"]["default_motor_vehicle_id"] == "citadine"
def test_home_assistant_section_absent_is_allowed(app):
with app.app_context():
from app.config_loader import get_home_assistant_config
app.config["TOML"].pop("home_assistant")
assert get_home_assistant_config() is None
@pytest.mark.parametrize(
("field", "value", "message"),
[
("timezone", "Mars/NoSuchPlace", "fuseau horaire"),
("day_type", "UNKNOWN", "type de journée"),
("journey_id", "unknown_journey", "trajet inconnu"),
("vehicle_id", "unknown_vehicle", "véhicule inconnu"),
],
)
def test_invalid_home_assistant_config_prevents_startup(tmp_path, field, value, message):
values = {
"timezone": "Europe/Paris",
"day_type": "WORK",
"journey_id": "moteur_seul",
"vehicle_id": "citadine",
}
values[field] = value
with pytest.raises(ValueError, match=message):
_create_app_with_home_assistant_config(tmp_path, **values)
def test_home_assistant_default_vehicle_must_be_motor_vehicle(tmp_path):
values = {
"timezone": "Europe/Paris",
"day_type": "WORK",
"journey_id": "moteur_seul",
"vehicle_id": "velo",
}
with pytest.raises(ValueError, match="véhicule moteur"):
_create_app_with_home_assistant_config(tmp_path, **values)
def test_incomplete_home_assistant_config_prevents_startup(tmp_path):
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[vehicles.citadine]
type = "moteur"
[journeys.moteur_seul]
distances = { moteur = 1 }
[home_assistant]
timezone = "Europe/Paris"
""",
encoding="utf-8",
)
with pytest.raises(ValueError, match="clé.*manquante"):
create_app(
config_path=str(config_path),
database_uri=IN_MEMORY_DATABASE_URI,
engine_options=in_memory_engine_options(),
)

View File

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

View File

@@ -1,9 +1,7 @@
from datetime import date from app.models import WorkEntry, TimeSlot
import sqlalchemy as sa
from app import db from app import db
from app.models import WorkEntry from datetime import date, time
import sqlalchemy as sa
def test_dashboard_empty(client): def test_dashboard_empty(client):
@@ -19,23 +17,21 @@ def test_entry_form_get(client):
def test_create_entry(client, app): def test_create_entry(client, app):
response = client.post( response = client.post("/entries/new", data={
"/entries/new", "date": "2025-06-02",
data={ "day_type": "WORK",
"date": "2025-06-02", "journey_profile_id": "moteur_seul",
"day_type": "WORK", "motor_vehicle_id": "familiale",
"journey_profile_id": "moteur_seul", "start_time": ["09:00"],
"motor_vehicle_id": "familiale", "end_time": ["17:45"],
"start_time": ["09:00"], "comment": "",
"end_time": ["17:45"], }, follow_redirects=True)
"comment": "",
},
follow_redirects=True,
)
assert response.status_code == 200 assert response.status_code == 200
with app.app_context(): 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 is not None
assert entry.day_type == "WORK" assert entry.day_type == "WORK"
assert len(entry.time_slots) == 1 assert len(entry.time_slots) == 1
@@ -70,36 +66,28 @@ def test_delete_entry(client, app):
assert response.status_code == 200 assert response.status_code == 200
with app.app_context(): 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 assert deleted is None
def test_create_entry_velo_no_motor_vehicle(client, app): def test_create_entry_velo_no_motor_vehicle(client, app):
"""Un trajet vélo seul ne doit pas enregistrer de motor_vehicle_id.""" """Un trajet vélo seul ne doit pas enregistrer de motor_vehicle_id."""
response = client.post( response = client.post("/entries/new", data={
"/entries/new", "date": "2025-06-10",
data={ "day_type": "WORK",
"date": "2025-06-10", "journey_profile_id": "velo_seul",
"day_type": "WORK", "motor_vehicle_id": "",
"journey_profile_id": "velo_seul", "start_time": ["08:30"],
"motor_vehicle_id": "", "end_time": ["17:00"],
"start_time": ["08:30"], "comment": "",
"end_time": ["17:00"], }, follow_redirects=True)
"comment": "",
},
follow_redirects=True,
)
assert response.status_code == 200 assert response.status_code == 200
with app.app_context(): 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 is not None
assert entry.motor_vehicle_id is None assert entry.motor_vehicle_id is None
def test_reports_monthly_section(client):
response = client.get("/reports/")
assert response.status_code == 200
assert "Détail mensuel" in response.text
assert "Janvier" in response.text
assert "Décembre" in response.text

View File

@@ -1,14 +1,8 @@
from datetime import date
from datetime import time as dtime
from app.business.time_calc import ( from app.business.time_calc import (
count_day_types,
minutes_to_str, minutes_to_str,
monthly_stats,
week_balance_minutes,
work_minutes_reference, work_minutes_reference,
week_balance_minutes,
) )
from app.models import TimeSlot, WorkEntry
def test_minutes_to_str_basic(): def test_minutes_to_str_basic():
@@ -48,6 +42,11 @@ def test_week_balance_negative():
assert week_balance_minutes(2200, 2325) == -125 assert week_balance_minutes(2200, 2325) == -125
from app.business.time_calc import count_day_types
from app.models import WorkEntry
from datetime import date
def test_count_day_types_basic(): def test_count_day_types_basic():
entries = [ entries = [
WorkEntry(date=date(2025, 1, 2), day_type="WORK"), WorkEntry(date=date(2025, 1, 2), day_type="WORK"),
@@ -63,65 +62,6 @@ def test_count_day_types_empty():
assert count_day_types([]) == {} assert count_day_types([]) == {}
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(":")]),
)
for s, e in slots
]
return entry
def _absence(d: date) -> WorkEntry:
"""Helper : crée un WorkEntry sans time_slots (0 min)."""
entry = WorkEntry(date=d, day_type="CONGE")
entry.time_slots = []
return entry
def test_monthly_stats_empty():
result = monthly_stats([])
assert result == {"median_daily_min": 0, "median_weekly_min": 0}
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
]
result = monthly_stats(entries)
assert result["median_daily_min"] == 465
def test_monthly_stats_includes_absences():
# 0, 465 → médiane de 2 valeurs = (0+465)/2 = 232 (int)
entries = [
_absence(date(2025, 1, 6)),
_entry(date(2025, 1, 7), ("9:00", "16:45")),
]
result = monthly_stats(entries)
assert result["median_daily_min"] == 232
def test_monthly_stats_median_weekly():
# Semaine 1 : 465+465 = 930 min
# Semaine 2 : 420 min
# médiane([930, 420]) = (420+930)/2 = 675
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
]
result = monthly_stats(entries)
assert result["median_weekly_min"] == 675
def test_count_day_types_single_type(): def test_count_day_types_single_type():
entries = [ entries = [
WorkEntry(date=date(2025, 2, 1), day_type="RTT"), WorkEntry(date=date(2025, 2, 1), day_type="RTT"),

View File

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