11 Commits

Author SHA1 Message Date
e9977f5a1b doc: clarify Python version requirement
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 12:02:15 +02:00
fd128dd09b chore: resolve final review findings
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Pro <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:48:38 +02:00
21fd877d7a doc: add developer onboarding guide
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:38:04 +02:00
bd5bc496eb doc: document configuration and application routes
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:05:10 +02:00
2d85fffd8d doc: document models and business rules
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Pro <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 10:58:49 +02:00
c7a0a77d1f chore: fix python style violations
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: OpenAI/GPT-5.6-Luna <vibecoder@antoineve.me>
Co-authored-by: MiniMax/MiniMax-M3 <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 10:14:21 +02:00
82beb6241f chore: configure python quality tooling
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: OpenAI/GPT-5.6-Luna <vibecoder@antoineve.me>
2026-08-13 10:08:13 +02:00
bfa48ee8a8 Add bulk CSV import script for work entries
- Add scripts/import_csv.py for direct SQLite database import
- Handle date conflicts with warnings (existing data preserved)
- Support multiple time slots per entry (semicolon-separated)
- Validate day_type, journey_profile_id, and motor_vehicle_id
- Update README.md with usage instructions
2026-05-13 18:45:27 +02:00
0956b22986 Add 2026 kilometer rate scale (unchanged from 2025) 2026-05-13 18:21:13 +02:00
8fe13615d0 CLAUDE.md -> AGENTS.md 2026-05-13 18:12:54 +02:00
7e2f158c09 Merge pull request 'feat: statistiques mensuelles dans la page rapports' (#2) from monthly-stats-reports into master
Reviewed-on: #2
2026-03-13 14:42:37 +01:00
24 changed files with 1235 additions and 141 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@@ -1,15 +1,41 @@
"""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
import sqlalchemy as sa
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import tomllib
import os
import sqlalchemy as sa
db = SQLAlchemy()
def _migrate_db(app):
"""Applique les migrations de schéma manquantes (pas d'Alembic)."""
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic).
Cette fonction vérifie l'état actuel de la base de données SQLite avant d'exécuter
des instructions DDL (comme `ALTER TABLE`). Elle permet d'éviter les erreurs si la table
`work_entries` n'existe pas encore (auquel cas `db.create_all()` s'en charge) ou si la
colonne `motor_vehicle_id` a déjà été ajoutée lors d'un démarrage précédent.
Paramètres:
app (Flask): L'instance de l'application Flask en cours d'initialisation.
"""
import sqlite3
db_path = os.path.join(app.instance_path, "worklog.db")
if not os.path.exists(db_path):
return # Nouvelle DB, create_all() s'en charge
@@ -25,48 +51,102 @@ def _migrate_db(app):
engine = db.engine
if "motor_vehicle_id" not in columns:
with engine.connect() as conn:
conn.execute(sa.text(
"ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)"
))
conn.execute(
sa.text("ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)")
)
conn.commit()
_JOURS_FR = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
_MOIS_FR = ["", "janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"]
_MOIS_FR = [
"",
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
]
_DAY_TYPE_LABELS = {
"WORK": "Travail",
"TT": "Télétravail",
"GARDE": "Garde",
"ASTREINTE": "Astreinte",
"FORMATION": "Formation",
"RTT": "RTT",
"CONGE": "Congé",
"MALADE": "Maladie",
"FERIE": "Férié",
"WORK": "Travail",
"TT": "Télétravail",
"GARDE": "Garde",
"ASTREINTE": "Astreinte",
"FORMATION": "Formation",
"RTT": "RTT",
"CONGE": "Congé",
"MALADE": "Maladie",
"FERIE": "Férié",
}
def _day_type_fr(code):
"""Filtre Jinja2 pour traduire un code de type de jour en libellé français.
Exemple:
`{{ 'WORK' | day_type_fr }}` -> "Travail"
Paramètres:
code (str): Le code interne du type de jour (ex: "WORK", "TT", "GARDE").
Retourne:
str: Le libellé en français correspondant, ou le code d'origine si aucune traduction n'est définie.
"""
return _DAY_TYPE_LABELS.get(code, code)
def _date_fr(d):
"""Formate une date en français : 'mercredi 11 mars 2026'."""
from datetime import date as date_type
"""Filtre Jinja2 pour formater une date en français lisible.
Exemple:
`{{ entry.date | date_fr }}` -> "mercredi 11 mars 2026"
Paramètres:
d (datetime.date): L'objet date à formater.
Retourne:
str: La date formatée en français (jour de la semaine, jour du mois, mois en toutes lettres, année).
"""
jour = _JOURS_FR[d.weekday()]
mois = _MOIS_FR[d.month]
return f"{jour} {d.day} {mois} {d.year}"
def create_app(config_path=None):
"""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.
Retourne:
Flask: L'instance de l'application Flask configurée et prête à l'emploi.
"""
app = Flask(__name__, instance_relative_config=True)
os.makedirs(app.instance_path, exist_ok=True)
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
app.config["SQLALCHEMY_DATABASE_URI"] = (
f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
@@ -86,6 +166,7 @@ def create_app(config_path=None):
from app.routes.dashboard import bp as dashboard_bp
from app.routes.entries import bp as entries_bp
from app.routes.reports import bp as reports_bp
app.register_blueprint(dashboard_bp)
app.register_blueprint(entries_bp)
app.register_blueprint(reports_bp)

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,76 @@
"""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 flask import current_app
def get_vehicles():
"""Récupère l'ensemble des véhicules configurés dans le fichier TOML.
Retourne:
dict: Un dictionnaire des véhicules où la clé est l'identifiant du véhicule
et la valeur est un dictionnaire contenant ses propriétés (name, type, fuel, co2_per_km, cv).
Retourne un dictionnaire vide si aucune configuration n'est chargée.
"""
return current_app.config.get("TOML", {}).get("vehicles", {})
def get_motor_vehicles():
"""Retourne uniquement les véhicules de type 'moteur'."""
"""Filtre et retourne uniquement les véhicules de type 'moteur'.
Cette fonction exclut les véhicules alternatifs (comme les vélos) pour ne conserver
que ceux qui possèdent une puissance fiscale (CV) et sont éligibles au barème kilométrique.
Retourne:
dict: Un dictionnaire contenant uniquement les véhicules dont le type est 'moteur'.
"""
return {k: v for k, v in get_vehicles().items() if v.get("type") == "moteur"}
def get_journeys():
"""Récupère l'ensemble des profils de trajets configurés dans le fichier TOML.
Retourne:
dict: Un dictionnaire des trajets où la clé est l'identifiant du trajet
et la valeur est un dictionnaire contenant ses propriétés (name, distances).
Retourne un dictionnaire vide si aucune configuration n'est chargée.
"""
return current_app.config.get("TOML", {}).get("journeys", {})
def journey_has_motor(journey_profile_id: str | None) -> bool:
"""Retourne True si le profil de trajet inclut un véhicule à moteur."""
"""Vérifie si un profil de trajet donné inclut une distance pour véhicule à moteur.
Cette validation permet de déterminer si l'utilisateur doit sélectionner un véhicule
à moteur lors de la saisie d'une journée de travail avec ce trajet.
Paramètres:
journey_profile_id (str | None): L'identifiant du profil de trajet à vérifier.
Retourne:
bool: True si le trajet existe et définit une distance pour la clé 'moteur',
False sinon ou si l'identifiant est nul.
"""
if not journey_profile_id:
return False
journeys = get_journeys()
@@ -24,6 +79,24 @@ def journey_has_motor(journey_profile_id: str | None) -> bool:
def get_bareme(year: int, cv: int) -> list[dict]:
"""Récupère les tranches du barème kilométrique pour une année et une puissance fiscale données.
Le barème kilométrique officiel est structuré en tranches de distances annuelles.
Cette fonction sélectionne la bonne catégorie de puissance fiscale (CV) :
- cv <= 3 -> 'cv_3'
- cv == 4 -> 'cv_4'
- cv == 5 -> 'cv_5'
- cv == 6 -> 'cv_6'
- cv >= 7 -> 'cv_7plus'
Paramètres:
year (int): L'année civile concernée par le calcul.
cv (int): La puissance fiscale du véhicule en chevaux fiscaux.
Retourne:
list[dict]: Une liste de dictionnaires représentant les tranches applicables.
Chaque tranche contient 'km_max' (0 si pas de limite), 'taux' et 'forfait'.
"""
bareme = current_app.config.get("TOML", {}).get("bareme_kilometrique", {})
year_data = bareme.get(str(year), {})
if cv <= 3:
@@ -40,4 +113,12 @@ def get_bareme(year: int, cv: int) -> list[dict]:
def day_types_without_journey():
"""Retourne l'ensemble des types de jours qui n'impliquent aucun trajet physique.
Ces types de jours (Télétravail, Maladie, Congé, RTT, Férié) sont exemptés de la saisie
de trajets ou de véhicules, car le travail s'effectue à distance ou l'employé est absent.
Retourne:
set[str]: Un ensemble de codes de types de jours (ex: {"TT", "MALADE", ...}).
"""
return {"TT", "MALADE", "CONGE", "RTT", "FERIE"}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -115,3 +115,82 @@ forfait = 1515
km_max = 0
taux = 0.470
forfait = 0
# --- Barème kilométrique voitures 2026 (revenus 2025) ---
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
[[bareme_kilometrique.2026.cv_3.tranches]]
km_max = 5000
taux = 0.529
forfait = 0
[[bareme_kilometrique.2026.cv_3.tranches]]
km_max = 20000
taux = 0.316
forfait = 1065
[[bareme_kilometrique.2026.cv_3.tranches]]
km_max = 0
taux = 0.370
forfait = 0
[[bareme_kilometrique.2026.cv_4.tranches]]
km_max = 5000
taux = 0.606
forfait = 0
[[bareme_kilometrique.2026.cv_4.tranches]]
km_max = 20000
taux = 0.340
forfait = 1330
[[bareme_kilometrique.2026.cv_4.tranches]]
km_max = 0
taux = 0.407
forfait = 0
[[bareme_kilometrique.2026.cv_5.tranches]]
km_max = 5000
taux = 0.636
forfait = 0
[[bareme_kilometrique.2026.cv_5.tranches]]
km_max = 20000
taux = 0.357
forfait = 1395
[[bareme_kilometrique.2026.cv_5.tranches]]
km_max = 0
taux = 0.427
forfait = 0
[[bareme_kilometrique.2026.cv_6.tranches]]
km_max = 5000
taux = 0.665
forfait = 0
[[bareme_kilometrique.2026.cv_6.tranches]]
km_max = 20000
taux = 0.374
forfait = 1457
[[bareme_kilometrique.2026.cv_6.tranches]]
km_max = 0
taux = 0.447
forfait = 0
[[bareme_kilometrique.2026.cv_7plus.tranches]]
km_max = 5000
taux = 0.697
forfait = 0
[[bareme_kilometrique.2026.cv_7plus.tranches]]
km_max = 20000
taux = 0.394
forfait = 1515
[[bareme_kilometrique.2026.cv_7plus.tranches]]
km_max = 0
taux = 0.470
forfait = 0

275
docs/onboarding.md Normal file
View File

@@ -0,0 +1,275 @@
# 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).
*Note : Ces données sont chargées en mémoire au démarrage de l'application dans `app.config["TOML"]`.*
### 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.
### Dépréciations à surveiller
- **`datetime.utcnow()`** : Utilisé dans les modèles de données. Cette méthode est dépréciée depuis Python 3.12. Lors d'une prochaine évolution majeure des modèles, il faudra la remplacer par `datetime.now(UTC)`.
- **`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.

16
pyproject.toml Normal file
View File

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

View File

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

166
scripts/import_csv.py Executable file
View File

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

View File

@@ -1,11 +1,14 @@
import pytest
from app import create_app, db as _db
from app import create_app
from app import db as _db
@pytest.fixture
def app(tmp_path):
config_path = tmp_path / "config.toml"
config_path.write_text("""
config_path.write_text(
"""
[vehicles.citadine]
name = "Citadine électrique"
fuel = "electric"
@@ -59,7 +62,9 @@ forfait = 699
km_max = 0
taux = 0.364
forfait = 0
""", encoding="utf-8")
""",
encoding="utf-8",
)
application = create_app(config_path=str(config_path))
application.config["TESTING"] = True

View File

@@ -1,6 +1,7 @@
def test_get_vehicles_returns_configured_vehicles(app):
with app.app_context():
from app.config_loader import get_vehicles
vehicles = get_vehicles()
assert "familiale" in vehicles
assert vehicles["familiale"]["co2_per_km"] == 142
@@ -9,6 +10,7 @@ def test_get_vehicles_returns_configured_vehicles(app):
def test_get_motor_vehicles_excludes_velo(app):
with app.app_context():
from app.config_loader import get_motor_vehicles
motor = get_motor_vehicles()
assert "familiale" in motor
assert "citadine" in motor
@@ -19,6 +21,7 @@ def test_get_motor_vehicles_excludes_velo(app):
def test_get_journeys_returns_profiles(app):
with app.app_context():
from app.config_loader import get_journeys
journeys = get_journeys()
assert "moteur_seul" in journeys
assert journeys["moteur_seul"]["distances"]["moteur"] == 25
@@ -27,6 +30,7 @@ def test_get_journeys_returns_profiles(app):
def test_journey_has_motor_true(app):
with app.app_context():
from app.config_loader import journey_has_motor
assert journey_has_motor("moteur_seul") is True
assert journey_has_motor("moteur_velo") is True
@@ -34,6 +38,7 @@ def test_journey_has_motor_true(app):
def test_journey_has_motor_false(app):
with app.app_context():
from app.config_loader import journey_has_motor
assert journey_has_motor("velo_seul") is False
assert journey_has_motor(None) is False
@@ -41,6 +46,7 @@ def test_journey_has_motor_false(app):
def test_get_bareme_returns_tranches(app):
with app.app_context():
from app.config_loader import get_bareme
tranches = get_bareme(2025, 5)
assert len(tranches) == 3
assert tranches[0]["taux"] == 0.548
@@ -49,6 +55,7 @@ def test_get_bareme_returns_tranches(app):
def test_day_types_without_journey(app):
with app.app_context():
from app.config_loader import day_types_without_journey
types = day_types_without_journey()
assert "TT" in types
assert "WORK" not in types

View File

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

View File

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

View File

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

View File

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