Compare commits
7 Commits
bfa48ee8a8
...
e9977f5a1b
| Author | SHA1 | Date | |
|---|---|---|---|
|
e9977f5a1b
|
|||
| fd128dd09b | |||
| 21fd877d7a | |||
| bd5bc496eb | |||
| 2d85fffd8d | |||
| c7a0a77d1f | |||
| 82beb6241f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ __pycache__/
|
|||||||
instance/
|
instance/
|
||||||
.env
|
.env
|
||||||
*.db
|
*.db
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -74,6 +74,19 @@ 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
|
### Import bulk depuis CSV
|
||||||
|
|
||||||
Un script est disponible pour importer des entrées en masse depuis un fichier CSV :
|
Un script est disponible pour importer des entrées en masse depuis un fichier CSV :
|
||||||
|
|||||||
123
app/__init__.py
123
app/__init__.py
@@ -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 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 (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
|
import sqlite3
|
||||||
|
|
||||||
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
|
||||||
@@ -25,48 +51,102 @@ 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(sa.text(
|
conn.execute(
|
||||||
"ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)"
|
sa.text("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 = ["", "janvier", "février", "mars", "avril", "mai", "juin",
|
_MOIS_FR = [
|
||||||
"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):
|
||||||
"""Formate une date en français : 'mercredi 11 mars 2026'."""
|
"""Filtre Jinja2 pour formater une date en français lisible.
|
||||||
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(config_path=None):
|
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)
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
|
|
||||||
os.makedirs(app.instance_path, exist_ok=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["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")
|
||||||
|
|
||||||
@@ -86,6 +166,7 @@ def create_app(config_path=None):
|
|||||||
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)
|
||||||
|
|||||||
@@ -1,34 +1,75 @@
|
|||||||
from app import db
|
|
||||||
from app.models import WorkEntry, LeaveBalance
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from datetime import date
|
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]:
|
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 = db.session.scalar(
|
conges = (
|
||||||
sa.select(sa.func.count()).where(
|
db.session.scalar(
|
||||||
WorkEntry.date.between(start, end),
|
sa.select(sa.func.count()).where(
|
||||||
WorkEntry.day_type == "CONGE",
|
WorkEntry.date.between(start, end),
|
||||||
|
WorkEntry.day_type == "CONGE",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
) or 0
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
rtt = db.session.scalar(
|
rtt = (
|
||||||
sa.select(sa.func.count()).where(
|
db.session.scalar(
|
||||||
WorkEntry.date.between(start, end),
|
sa.select(sa.func.count()).where(
|
||||||
WorkEntry.day_type == "RTT",
|
WorkEntry.date.between(start, end),
|
||||||
|
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(
|
"""
|
||||||
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:
|
if balance is None:
|
||||||
balance = LeaveBalance(year=year)
|
balance = LeaveBalance(year=year)
|
||||||
db.session.add(balance)
|
db.session.add(balance)
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
|
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}"
|
||||||
@@ -18,20 +30,51 @@ _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
|
||||||
|
|
||||||
|
|
||||||
import statistics as _stats
|
|
||||||
|
|
||||||
|
|
||||||
def monthly_stats(entries: list) -> dict:
|
def monthly_stats(entries: list) -> dict:
|
||||||
"""
|
"""
|
||||||
Calcule médiane journalière et médiane hebdomadaire (semaines ISO)
|
Calcule la médiane journalière et la médiane hebdomadaire (semaines ISO) pour un groupe d'entrées.
|
||||||
pour un groupe d'entrées. Les absences (total_minutes=0) sont incluses.
|
|
||||||
|
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:
|
if not entries:
|
||||||
return {"median_daily_min": 0, "median_weekly_min": 0}
|
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]:
|
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
|
||||||
|
|||||||
@@ -4,9 +4,22 @@ def compute_km_for_entry(
|
|||||||
motor_vehicle_id: str | None = None,
|
motor_vehicle_id: str | None = None,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""
|
"""
|
||||||
Retourne un dict {vehicle_id: km} pour un profil de trajet donné.
|
Calcule les distances parcourues par véhicule pour une entrée de journal donnée.
|
||||||
La clé générique 'moteur' est remplacée par motor_vehicle_id si fourni.
|
|
||||||
Retourne {} si pas de profil (TT, CONGE, etc.).
|
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:
|
if not journey_profile_id:
|
||||||
return {}
|
return {}
|
||||||
@@ -23,7 +36,18 @@ 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, {})
|
||||||
@@ -32,11 +56,26 @@ def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float:
|
|||||||
return total
|
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.
|
Calcule le montant des frais réels déductibles selon le barème kilométrique fiscal.
|
||||||
km_max = 0 signifie "pas de limite" (dernière tranche).
|
|
||||||
electric=True applique la majoration de 20 % pour véhicules électriques.
|
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:
|
if not tranches or total_km_moteur <= 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -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
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
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():
|
||||||
"""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"}
|
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 journey_has_motor(journey_profile_id: str | None) -> bool:
|
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:
|
if not journey_profile_id:
|
||||||
return False
|
return False
|
||||||
journeys = get_journeys()
|
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]:
|
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:
|
||||||
@@ -40,4 +113,12 @@ 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"}
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
from app import db
|
from datetime import date, datetime, time
|
||||||
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)
|
||||||
@@ -23,6 +37,17 @@ 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
|
||||||
@@ -33,11 +58,24 @@ 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)
|
||||||
@@ -49,6 +87,13 @@ 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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -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
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
from app import db
|
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.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 = 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
|
||||||
|
|
||||||
@@ -45,9 +75,7 @@ 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(
|
today_entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == today))
|
||||||
sa.select(WorkEntry).where(WorkEntry.date == today)
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
|
|||||||
@@ -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
|
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.models import WorkEntry, TimeSlot
|
from app.config_loader import (
|
||||||
from app.config_loader import get_journeys, get_motor_vehicles, day_types_without_journey, journey_has_motor
|
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")
|
bp = Blueprint("entries", __name__, url_prefix="/entries")
|
||||||
|
|
||||||
@@ -22,15 +47,55 @@ DAY_TYPES = [
|
|||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def list_entries():
|
def list_entries():
|
||||||
entries = db.session.scalars(
|
"""Affiche la liste historique de toutes les entrées de temps enregistrées.
|
||||||
sa.select(WorkEntry).order_by(WorkEntry.date.desc())
|
|
||||||
).all()
|
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)
|
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)
|
||||||
@@ -51,9 +116,7 @@ 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(
|
existing = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == entry_date))
|
||||||
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"))
|
||||||
@@ -72,11 +135,13 @@ 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(TimeSlot(
|
db.session.add(
|
||||||
entry=entry,
|
TimeSlot(
|
||||||
start_time=time.fromisoformat(s),
|
entry=entry,
|
||||||
end_time=time.fromisoformat(e),
|
start_time=time.fromisoformat(s),
|
||||||
))
|
end_time=time.fromisoformat(e),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Entrée enregistrée.", "success")
|
flash("Entrée enregistrée.", "success")
|
||||||
@@ -96,6 +161,21 @@ 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)
|
||||||
|
|||||||
@@ -1,24 +1,71 @@
|
|||||||
from flask import Blueprint, render_template, request
|
"""Blueprint des routes de génération des rapports et statistiques.
|
||||||
from datetime import date
|
|
||||||
|
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 collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
|
||||||
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, monthly_stats, minutes_to_str
|
|
||||||
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 = {
|
MONTHS_FR = {
|
||||||
1: "Janvier", 2: "Février", 3: "Mars", 4: "Avril",
|
1: "Janvier",
|
||||||
5: "Mai", 6: "Juin", 7: "Juillet", 8: "Août",
|
2: "Février",
|
||||||
9: "Septembre", 10: "Octobre", 11: "Novembre", 12: "Décembre",
|
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)
|
||||||
@@ -73,7 +120,9 @@ def index():
|
|||||||
"km_by_vehicle": month_km,
|
"km_by_vehicle": month_km,
|
||||||
"km_total": sum(month_km.values()),
|
"km_total": sum(month_km.values()),
|
||||||
"median_daily_str": minutes_to_str(stats["median_daily_min"]) if month_entries else "–",
|
"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(
|
return render_template(
|
||||||
|
|||||||
275
docs/onboarding.md
Normal file
275
docs/onboarding.md
Normal 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
16
pyproject.toml
Normal 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"
|
||||||
@@ -3,3 +3,4 @@ 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
|
||||||
|
|||||||
@@ -27,16 +27,13 @@ import sqlalchemy as sa
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from app import create_app, db
|
from app import create_app, db
|
||||||
from app.models import WorkEntry, TimeSlot
|
|
||||||
from app.config_loader import day_types_without_journey, journey_has_motor
|
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"}
|
||||||
|
|
||||||
|
|
||||||
DAY_TYPES = {
|
def main(csv_path: str, config_path: str | None = None):
|
||||||
"WORK", "TT", "GARDE", "ASTREINTE", "FORMATION", "RTT", "CONGE", "MALADE", "FERIE"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main(csv_path: str, config_path: str = None):
|
|
||||||
"""Importe les données depuis un fichier CSV vers la base de données."""
|
"""Importe les données depuis un fichier CSV vers la base de données."""
|
||||||
|
|
||||||
# Créer l'application Flask avec la config
|
# Créer l'application Flask avec la config
|
||||||
@@ -85,10 +82,10 @@ def main(csv_path: str, config_path: str = None):
|
|||||||
if existing:
|
if existing:
|
||||||
# Vérifier si les données sont différentes
|
# Vérifier si les données sont différentes
|
||||||
is_different = (
|
is_different = (
|
||||||
existing.day_type != day_type or
|
existing.day_type != day_type
|
||||||
existing.journey_profile_id != journey_profile_id or
|
or existing.journey_profile_id != journey_profile_id
|
||||||
existing.motor_vehicle_id != motor_vehicle_id or
|
or existing.motor_vehicle_id != motor_vehicle_id
|
||||||
existing.comment != comment
|
or existing.comment != comment
|
||||||
)
|
)
|
||||||
if is_different:
|
if is_different:
|
||||||
conflicts.append(
|
conflicts.append(
|
||||||
@@ -116,13 +113,17 @@ def main(csv_path: str, config_path: str = None):
|
|||||||
e = e.strip()
|
e = e.strip()
|
||||||
if s and e:
|
if s and e:
|
||||||
try:
|
try:
|
||||||
db.session.add(TimeSlot(
|
db.session.add(
|
||||||
entry=entry,
|
TimeSlot(
|
||||||
start_time=time.fromisoformat(s),
|
entry=entry,
|
||||||
end_time=time.fromisoformat(e),
|
start_time=time.fromisoformat(s),
|
||||||
))
|
end_time=time.fromisoformat(e),
|
||||||
|
)
|
||||||
|
)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
conflicts.append(f"Ligne {row_num}: format d'heure invalide '{s}' ou '{e}'")
|
conflicts.append(
|
||||||
|
f"Ligne {row_num}: format d'heure invalide '{s}' ou '{e}'"
|
||||||
|
)
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -135,7 +136,7 @@ def main(csv_path: str, config_path: str = None):
|
|||||||
# Valider et commiter
|
# Valider et commiter
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except sa.exc.SQLAlchemyError as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
print(f"Erreur lors du commit: {e}", file=sys.stderr)
|
print(f"Erreur lors du commit: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
@@ -156,7 +157,9 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Importer un fichier CSV dans la base de données")
|
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("csv_file", help="Chemin vers le fichier CSV à importer")
|
||||||
parser.add_argument("--config", default=None, help="Chemin vers le fichier config.toml (optionnel)")
|
parser.add_argument(
|
||||||
|
"--config", default=None, help="Chemin vers le fichier config.toml (optionnel)"
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from app import create_app, db as _db
|
|
||||||
|
from app import create_app
|
||||||
|
from app import db as _db
|
||||||
|
|
||||||
|
|
||||||
@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"
|
||||||
@@ -59,7 +62,9 @@ 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(config_path=str(config_path))
|
application = create_app(config_path=str(config_path))
|
||||||
application.config["TESTING"] = True
|
application.config["TESTING"] = True
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -9,6 +10,7 @@ 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
|
||||||
@@ -19,6 +21,7 @@ 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
|
||||||
@@ -27,6 +30,7 @@ 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
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ 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
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ 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
|
||||||
@@ -49,6 +55,7 @@ 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
|
||||||
|
|||||||
@@ -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
|
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):
|
def test_compute_leave_used_conges(app):
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from app.models import WorkEntry, TimeSlot
|
from datetime import date
|
||||||
from app import db
|
|
||||||
from datetime import date, time
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import WorkEntry
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_empty(client):
|
def test_dashboard_empty(client):
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
@@ -17,21 +19,23 @@ def test_entry_form_get(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_create_entry(client, app):
|
def test_create_entry(client, app):
|
||||||
response = client.post("/entries/new", data={
|
response = client.post(
|
||||||
"date": "2025-06-02",
|
"/entries/new",
|
||||||
"day_type": "WORK",
|
data={
|
||||||
"journey_profile_id": "moteur_seul",
|
"date": "2025-06-02",
|
||||||
"motor_vehicle_id": "familiale",
|
"day_type": "WORK",
|
||||||
"start_time": ["09:00"],
|
"journey_profile_id": "moteur_seul",
|
||||||
"end_time": ["17:45"],
|
"motor_vehicle_id": "familiale",
|
||||||
"comment": "",
|
"start_time": ["09:00"],
|
||||||
}, follow_redirects=True)
|
"end_time": ["17:45"],
|
||||||
|
"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(
|
entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 2)))
|
||||||
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
|
||||||
@@ -66,29 +70,29 @@ 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(
|
deleted = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.id == entry_id))
|
||||||
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("/entries/new", data={
|
response = client.post(
|
||||||
"date": "2025-06-10",
|
"/entries/new",
|
||||||
"day_type": "WORK",
|
data={
|
||||||
"journey_profile_id": "velo_seul",
|
"date": "2025-06-10",
|
||||||
"motor_vehicle_id": "",
|
"day_type": "WORK",
|
||||||
"start_time": ["08:30"],
|
"journey_profile_id": "velo_seul",
|
||||||
"end_time": ["17:00"],
|
"motor_vehicle_id": "",
|
||||||
"comment": "",
|
"start_time": ["08:30"],
|
||||||
}, follow_redirects=True)
|
"end_time": ["17:00"],
|
||||||
|
"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(
|
entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 10)))
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
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,
|
||||||
work_minutes_reference,
|
monthly_stats,
|
||||||
week_balance_minutes,
|
week_balance_minutes,
|
||||||
|
work_minutes_reference,
|
||||||
)
|
)
|
||||||
|
from app.models import TimeSlot, WorkEntry
|
||||||
|
|
||||||
|
|
||||||
def test_minutes_to_str_basic():
|
def test_minutes_to_str_basic():
|
||||||
@@ -42,12 +48,6 @@ 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, TimeSlot
|
|
||||||
from app.business.time_calc import monthly_stats
|
|
||||||
from datetime import date, time as dtime
|
|
||||||
|
|
||||||
|
|
||||||
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"),
|
||||||
@@ -67,8 +67,10 @@ def _entry(d: date, *slots: tuple[str, str]) -> WorkEntry:
|
|||||||
"""Helper : crée un WorkEntry avec des TimeSlots."""
|
"""Helper : crée un WorkEntry avec des TimeSlots."""
|
||||||
entry = WorkEntry(date=d, day_type="WORK")
|
entry = WorkEntry(date=d, day_type="WORK")
|
||||||
entry.time_slots = [
|
entry.time_slots = [
|
||||||
TimeSlot(start_time=dtime(*[int(x) for x in s.split(":")]),
|
TimeSlot(
|
||||||
end_time=dtime(*[int(x) for x in e.split(":")]))
|
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
|
for s, e in slots
|
||||||
]
|
]
|
||||||
return entry
|
return entry
|
||||||
@@ -89,9 +91,9 @@ def test_monthly_stats_empty():
|
|||||||
def test_monthly_stats_median_daily_odd():
|
def test_monthly_stats_median_daily_odd():
|
||||||
# 420, 465, 510 → médiane = 465
|
# 420, 465, 510 → médiane = 465
|
||||||
entries = [
|
entries = [
|
||||||
_entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 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, 7), ("9:00", "16:45")), # 465 min
|
||||||
_entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min
|
_entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min
|
||||||
]
|
]
|
||||||
result = monthly_stats(entries)
|
result = monthly_stats(entries)
|
||||||
assert result["median_daily_min"] == 465
|
assert result["median_daily_min"] == 465
|
||||||
@@ -114,7 +116,7 @@ def test_monthly_stats_median_weekly():
|
|||||||
entries = [
|
entries = [
|
||||||
_entry(date(2025, 1, 6), ("9:00", "16:45")), # sem 2
|
_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, 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)
|
result = monthly_stats(entries)
|
||||||
assert result["median_weekly_min"] == 675
|
assert result["median_weekly_min"] == 675
|
||||||
|
|||||||
@@ -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},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user