diff --git a/app/__init__.py b/app/__init__.py index c59b50b..5856e50 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,18 @@ +"""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 sqlalchemy as sa @@ -9,7 +24,16 @@ 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") @@ -65,17 +89,57 @@ _DAY_TYPE_LABELS = { 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'.""" + """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) diff --git a/app/config_loader.py b/app/config_loader.py index 5764f69..50368e7 100644 --- a/app/config_loader.py +++ b/app/config_loader.py @@ -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.), + et optionnellement `cv` (puissance fiscale pour le barème kilométrique). +2. Trajets (`[journeys]`) : + - Profils de trajets prédéfinis (ex: "domicile-travail"). + - 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"), + `coeff` (coefficient multiplicateur par km) et `fixe` (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, 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), 'coeff' et 'fixe'. + """ 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"} diff --git a/app/routes/__init__.py b/app/routes/__init__.py index e69de29..2ba8595 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -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. +""" diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 88b187a..516cc26 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,3 +1,10 @@ +"""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 @@ -15,6 +22,27 @@ 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 diff --git a/app/routes/entries.py b/app/routes/entries.py index 2b62609..0113d27 100644 --- a/app/routes/entries.py +++ b/app/routes/entries.py @@ -1,3 +1,21 @@ +"""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 @@ -29,6 +47,17 @@ DAY_TYPES = [ @bp.route("/") def list_entries(): + """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) @@ -36,6 +65,37 @@ def list_entries(): @bp.route("/new", methods=["GET", "POST"]) @bp.route("//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 (`//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) @@ -101,6 +161,21 @@ def entry_form(entry_id=None): @bp.route("//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) diff --git a/app/routes/reports.py b/app/routes/reports.py index 1bd0a8b..c89ee65 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -1,3 +1,10 @@ +"""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 @@ -30,6 +37,35 @@ MONTHS_FR = { @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)