From 7b3c91509f0e82507537ba4e1cbb2949c9133f08 Mon Sep 17 00:00:00 2001 From: Anthony Coyaud Date: Sat, 15 Aug 2026 14:28:34 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(config):=20rend=20le=20serveur=20SQL=20?= =?UTF-8?q?configurable=20en=20d=C3=A9veloppement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le repli de développement pointait en dur sur LaptopCA\SQLEXPRESS, ce qui rendait l'API inutilisable sur toute autre machine sans éditer le code. Le serveur vient désormais de DB_SERVER (défaut localhost), et DB_TRUSTED_CONNECTION permet de forcer l'authentification Windows. DB_PORT peut rester vide : une instance nommée (MonPoste\SQLEXPRESS) se joint sans port explicite. --- config.py | 49 ++++++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/config.py b/config.py index 4b67bc7..b05fc59 100644 --- a/config.py +++ b/config.py @@ -10,27 +10,38 @@ from contextlib import contextmanager def _build_connection_string() -> str: """ - En déploiement (variables d'environnement présentes), on se connecte en - authentification SQL. Sinon, on garde la connexion Windows locale par défaut - (poste de dev). + Construit la chaîne de connexion ODBC à partir des variables + d'environnement. + + Deux modes d'authentification : + • authentification SQL (déploiement) — dès que DB_USER est défini ; + • authentification Windows (poste de dev) — sinon, ou en forçant + DB_TRUSTED_CONNECTION=yes. + + Le serveur par défaut reste modifiable par DB_SERVER : coder en dur le + nom d'un poste rendait l'API inutilisable sur toute autre machine. """ - server = os.getenv("DB_SERVER") - if server: - driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server") - port = os.getenv("DB_PORT", "1433") - return ( - f"Driver={{{driver}}};" - f"Server={server},{port};" - f"Database={os.getenv('DB_NAME', 'DataSentinel')};" - f"UID={os.getenv('DB_USER')};" - f"PWD={os.getenv('DB_PASSWORD')};" - "Encrypt=yes;TrustServerCertificate=yes;" - ) + server = os.getenv("DB_SERVER", "localhost") + port = os.getenv("DB_PORT", "") + base = os.getenv("DB_NAME", "DataSentinel") + driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server") + user = os.getenv("DB_USER") + + # Une instance nommée (MonPoste\SQLEXPRESS) se joint sans port explicite. + adresse = f"{server},{port}" if port else server + + trusted = os.getenv("DB_TRUSTED_CONNECTION", "").lower() in ("1", "yes", "true") + if user and not trusted: + identification = f"UID={user};PWD={os.getenv('DB_PASSWORD', '')};" + else: + identification = "Trusted_Connection=yes;" + return ( - "Driver={ODBC Driver 17 for SQL Server};" - "Server=LaptopCA\\SQLEXPRESS;" - "Database=DataSentinel;" - "Trusted_Connection=yes;" + f"Driver={{{driver}}};" + f"Server={adresse};" + f"Database={base};" + f"{identification}" + "Encrypt=yes;TrustServerCertificate=yes;" ) From 934e6697748bc764225db85d7f57aded61829c42 Mon Sep 17 00:00:00 2001 From: Anthony Coyaud Date: Sat, 15 Aug 2026 14:28:45 +0200 Subject: [PATCH 2/3] =?UTF-8?q?refactor(api):=20d=C3=A9coupe=20main.py=20e?= =?UTF-8?q?n=20routeurs=20par=20domaine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.py comptait 829 lignes et regroupait 9 domaines fonctionnels. Il ne fait plus que l'assemblage de l'application (configuration, middlewares, montage des routeurs), soit 115 lignes. - routers/ : un module par domaine, chacun déclarant sa propre dépendance d'authentification - domain.py : enums UserRole et AuditAction, mapping MONITO_TABLES ; les rôles étaient jusqu'ici répétés en dur à deux endroits - helpers.py : conversion des lignes pyodbc, écriture du journal - rate_limit.py : limiteur partagé, isolé pour éviter un import circulaire entre main.py et le routeur d'authentification Les codes HTTP littéraux (404, 401, 400, 201) passent aux constantes fastapi.status, comme le faisait déjà auth.py. Les actions du journal d'audit passent en paramètre SQL au lieu d'être concaténées. Aucune route modifiée : la comparaison des specs OpenAPI avant/après confirme que les 26 URL existantes sont identiques. conftest patchait main.get_cursor ; chaque routeur important désormais get_cursor dans son propre espace de noms, la fixture remplace le nom dans tous les modules concernés. --- domain.py | 51 +++ helpers.py | 51 +++ main.py | 774 ++---------------------------------- rate_limit.py | 11 + routers/__init__.py | 0 routers/admin.py | 104 +++++ routers/authentification.py | 47 +++ routers/dashboard.py | 91 +++++ routers/evolution.py | 158 ++++++++ routers/historique.py | 110 +++++ routers/monitorings.py | 215 ++++++++++ routers/referentiels.py | 233 +++++++++++ routers/rgpd.py | 43 ++ tests/conftest.py | 22 +- 14 files changed, 1165 insertions(+), 745 deletions(-) create mode 100644 domain.py create mode 100644 helpers.py create mode 100644 rate_limit.py create mode 100644 routers/__init__.py create mode 100644 routers/admin.py create mode 100644 routers/authentification.py create mode 100644 routers/dashboard.py create mode 100644 routers/evolution.py create mode 100644 routers/historique.py create mode 100644 routers/monitorings.py create mode 100644 routers/referentiels.py create mode 100644 routers/rgpd.py diff --git a/domain.py b/domain.py new file mode 100644 index 0000000..82b8b9b --- /dev/null +++ b/domain.py @@ -0,0 +1,51 @@ +# ============================================================ +# domain.py — Constantes et énumérations du domaine Data Sentinel +# ============================================================ + +from enum import Enum + + +class UserRole(str, Enum): + """Rôles applicatifs — miroir de la contrainte CK_USER_ROLE en base.""" + ADMIN = "Admin" + SUPERVISEUR = "Superviseur" + CONSULTANT = "Consultant" + + +class AuditAction(str, Enum): + """Actions tracées dans JOURNAL_AUDIT.""" + LOGIN = "LOGIN" + CREATE_USER = "CREATE_USER" + UPDATE_USER = "UPDATE_USER" + DELETE_USER = "DELETE_USER" + RESET_PASSWORD = "RESET_PASSWORD" + CREATE_REFERENTIEL = "CREATE_REFERENTIEL" + UPDATE_REFERENTIEL = "UPDATE_REFERENTIEL" + DELETE_REFERENTIEL = "DELETE_REFERENTIEL" + + +# ============================================================ +# Mapping id_monito → table SQL dédiée +# Pour ajouter un monitoring : ajouter une entrée ici. +# ============================================================ + +MONITO_TABLES: dict[int, str] = { + 1: "MONITO_TIERS_PAYEURS", + 2: "MONITO_PRELEVEMENT_SANS_RIB", + 3: "MONITO_DOM_TOM_TVA", + 4: "MONITO_ZONE_VENTES_CRM", + 5: "MONITO_MULTI_MODES_REGLEMENT", + 6: "MONITO_SANS_SIRET", + 7: "MONITO_SANS_CONTREPARTIE", +} + +# Colonnes de recherche textuelle par table (pour le filtre search) +SEARCH_COLS: dict[str, list[str]] = { + "MONITO_TIERS_PAYEURS": ["CT_Intitule", "origineClient", "STE"], + "MONITO_PRELEVEMENT_SANS_RIB": ["CT_Intitule", "STE"], + "MONITO_DOM_TOM_TVA": ["CT_Intitule", "agence", "CT_Pays"], + "MONITO_ZONE_VENTES_CRM": ["name", "Agence", "xefi_sagedatabase"], + "MONITO_MULTI_MODES_REGLEMENT": ["CT_Intitule", "Agence"], + "MONITO_SANS_SIRET": ["ct_intitule", "agence", "ct_num"], + "MONITO_SANS_CONTREPARTIE": ["agence", "EC_Piece", "CT_NumCont"], +} diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..595fa3e --- /dev/null +++ b/helpers.py @@ -0,0 +1,51 @@ +# ============================================================ +# helpers.py — Conversion des résultats pyodbc et journal d'audit +# ============================================================ + +from fastapi import HTTPException, status + +from domain import MONITO_TABLES, AuditAction + + +def rows_to_list(cursor, rows) -> list[dict]: + """Convertit les lignes pyodbc en liste de dicts.""" + cols = [col[0] for col in cursor.description] + return [dict(zip(cols, r)) for r in rows] + + +def row_to_dict(cursor, row) -> dict: + """Convertit une ligne pyodbc en dict.""" + cols = [col[0] for col in cursor.description] + return dict(zip(cols, row)) + + +def get_table_name(id_monito: int) -> str: + """Retourne le nom de la table dédiée. Lève 404 si inconnu.""" + table = MONITO_TABLES.get(id_monito) + if not table: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Monitoring {id_monito} introuvable. IDs valides : {list(MONITO_TABLES.keys())}" + ) + return table + + +def serialize_row(row: dict) -> dict: + """Convertit les types non-JSON (date, Decimal) en types sérialisables.""" + result = {} + for k, v in row.items(): + if v is None: + result[k] = None + elif hasattr(v, 'isoformat'): + result[k] = v.isoformat() + else: + result[k] = v + return result + + +def journaliser(cursor, acteur: dict, action: AuditAction, detail: str) -> None: + """Trace une action d'administration dans JOURNAL_AUDIT.""" + cursor.execute( + "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, ?, ?)", + acteur["id_user"], acteur["username"], action.value, detail + ) diff --git a/main.py b/main.py index 4dc1ffd..ec31b9b 100644 --- a/main.py +++ b/main.py @@ -3,27 +3,34 @@ # COYAUD Anthony | 2026 # V2 : une table dédiée par monitoring # +# Ce module ne contient que l'assemblage de l'application : +# configuration, middlewares et montage des routeurs. Chaque +# domaine fonctionnel vit dans routers/. +# # Lancement : uvicorn main:app --reload --port 8000 # Swagger : http://localhost:8000/docs # ============================================================ import os -from fastapi import FastAPI, HTTPException, Query, APIRouter, Depends, Request +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import OAuth2PasswordRequestForm -from pydantic import BaseModel -from typing import Optional -from datetime import date -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.util import get_remote_address +from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from config import Config, get_cursor -from auth import ( - verify_password, hash_password, create_access_token, - get_current_user, require_admin, +from domain import MONITO_TABLES +from rate_limit import limiter +from routers import ( + admin, + authentification, + dashboard, + evolution, + historique, + monitorings, + referentiels, + rgpd, ) # Origines autorisées : depuis CORS_ORIGINS (séparées par des virgules) en prod, @@ -45,7 +52,6 @@ app = FastAPI( ) # Limiteur de débit (anti brute-force sur /auth/login). -limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) @@ -69,570 +75,8 @@ async def security_headers(request: Request, call_next): return response -# Toutes les routes "données" passent par ce router, protégé par JWT. -# /health et /auth/* restent publics (déclarés sur `app`). -router = APIRouter(dependencies=[Depends(get_current_user)]) - # ============================================================ -# Mapping id_monito → table SQL dédiée -# Pour ajouter un monitoring : ajouter une entrée ici. -# ============================================================ - -MONITO_TABLES: dict[int, str] = { - 1: "MONITO_TIERS_PAYEURS", - 2: "MONITO_PRELEVEMENT_SANS_RIB", - 3: "MONITO_DOM_TOM_TVA", - 4: "MONITO_ZONE_VENTES_CRM", - 5: "MONITO_MULTI_MODES_REGLEMENT", - 6: "MONITO_SANS_SIRET", - 7: "MONITO_SANS_CONTREPARTIE", -} - -# Colonnes de recherche textuelle par table (pour le filtre search) -SEARCH_COLS: dict[str, list[str]] = { - "MONITO_TIERS_PAYEURS": ["CT_Intitule", "origineClient", "STE"], - "MONITO_PRELEVEMENT_SANS_RIB": ["CT_Intitule", "STE"], - "MONITO_DOM_TOM_TVA": ["CT_Intitule", "agence", "CT_Pays"], - "MONITO_ZONE_VENTES_CRM": ["name", "Agence", "xefi_sagedatabase"], - "MONITO_MULTI_MODES_REGLEMENT": ["CT_Intitule", "Agence"], - "MONITO_SANS_SIRET": ["ct_intitule", "agence", "ct_num"], - "MONITO_SANS_CONTREPARTIE": ["agence", "EC_Piece", "CT_NumCont"], -} - - -# ============================================================ -# Helpers -# ============================================================ - -def rows_to_list(cursor, rows) -> list[dict]: - """Convertit les lignes pyodbc en liste de dicts.""" - cols = [col[0] for col in cursor.description] - return [dict(zip(cols, r)) for r in rows] - - -def row_to_dict(cursor, row) -> dict: - """Convertit une ligne pyodbc en dict.""" - cols = [col[0] for col in cursor.description] - return dict(zip(cols, row)) - - -def get_table_name(id_monito: int) -> str: - """Retourne le nom de la table dédiée. Lève 404 si inconnu.""" - table = MONITO_TABLES.get(id_monito) - if not table: - raise HTTPException( - status_code=404, - detail=f"Monitoring {id_monito} introuvable. IDs valides : {list(MONITO_TABLES.keys())}" - ) - return table - - -def serialize_row(row: dict) -> dict: - """Convertit les types non-JSON (date, Decimal) en types sérialisables.""" - result = {} - for k, v in row.items(): - if v is None: - result[k] = None - elif hasattr(v, 'isoformat'): - result[k] = v.isoformat() - else: - result[k] = v - return result - - -# ============================================================ -# RÉFÉRENTIELS -# ============================================================ - -@router.get("/categories", tags=["Référentiels"]) -def get_categories(): - """Toutes les catégories de monitoring.""" - with get_cursor() as cursor: - cursor.execute( - "SELECT id_categorie, intitule_categorie " - "FROM CATEGORIE ORDER BY intitule_categorie" - ) - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/services", tags=["Référentiels"]) -def get_services(): - """Tous les services.""" - with get_cursor() as cursor: - cursor.execute("SELECT id_service, nom_service FROM SERVICE ORDER BY nom_service") - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/contacts", tags=["Référentiels"]) -def get_contacts( - id_service: Optional[int] = Query(None, description="Filtrer par service") -): - """Contacts, filtrables par service.""" - with get_cursor() as cursor: - if id_service: - cursor.execute( - "SELECT id_contact, id_service, intitule_contact, nom, prenom, mail " - "FROM CONTACT WHERE id_service = ? ORDER BY nom", - id_service - ) - else: - cursor.execute( - "SELECT id_contact, id_service, intitule_contact, nom, prenom, mail " - "FROM CONTACT ORDER BY nom" - ) - return rows_to_list(cursor, cursor.fetchall()) - - -# ============================================================ -# NOMENCLATURE -# ============================================================ - -@router.get("/monitorings", tags=["Monitorings"]) -def get_monitorings( - id_service : Optional[int] = Query(None, description="Filtrer par service"), - id_categorie : Optional[int] = Query(None, description="Filtrer par catégorie"), -): - """Liste des monitorings actifs avec table_source et bdd_source.""" - query = ( - "SELECT id_monito, monito_intitule, id_service, id_categorie, " - "table_source, bdd_source " - "FROM NOMENCLATURE_MONITO WHERE actif = 1" - ) - params = [] - - if id_service: - query += " AND id_service = ?" - params.append(id_service) - if id_categorie: - query += " AND id_categorie = ?" - params.append(id_categorie) - - query += " ORDER BY id_monito" - - with get_cursor() as cursor: - cursor.execute(query, *params) - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/monitorings/{id_monito}", tags=["Monitorings"]) -def get_monitoring_by_id(id_monito: int): - """Détail d'un monitoring.""" - with get_cursor() as cursor: - cursor.execute( - "SELECT id_monito, monito_intitule, id_service, id_categorie, " - "table_source, bdd_source " - "FROM NOMENCLATURE_MONITO WHERE id_monito = ? AND actif = 1", - id_monito - ) - row = cursor.fetchone() - if not row: - raise HTTPException(status_code=404, detail=f"Monitoring {id_monito} introuvable.") - return row_to_dict(cursor, row) - - -# ============================================================ -# DONNÉES DÉTAILLÉES — table dédiée par monitoring -# ============================================================ - -@router.get("/monitorings/{id_monito}/details", tags=["Monitorings"]) -def get_monitoring_details( - id_monito : int, - search : Optional[str] = Query( - None, - description="Recherche sur colonnes texte (ct_intitule, agence, STE...)" - ), - limit : int = Query(500, ge=1, le=5000, description="Lignes max — mettre 5000 pour export CSV complet"), - offset : int = Query(0, ge=0, description="Offset pagination"), -): - """ - Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée. - Toutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend. - """ - table = get_table_name(id_monito) - query = f"SELECT * FROM {table} WHERE 1=1" - params = [] - - if search and table in SEARCH_COLS: - cols = SEARCH_COLS[table] - conditions = " OR ".join([f"{col} LIKE ?" for col in cols]) - query += f" AND ({conditions})" - params.extend([f"%{search}%"] * len(cols)) - - query += " ORDER BY date_extraction DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" - params += [offset, limit] - - with get_cursor() as cursor: - cursor.execute(query, *params) - rows = rows_to_list(cursor, cursor.fetchall()) - return [serialize_row(r) for r in rows] - - -@router.get("/monitorings/{id_monito}/count", tags=["Monitorings"]) -def get_monitoring_count(id_monito: int): - """Nombre d'erreurs dans la table dédiée du monitoring.""" - table = get_table_name(id_monito) - with get_cursor() as cursor: - cursor.execute(f"SELECT COUNT(*) FROM {table}") - return { - "id_monito" : id_monito, - "table" : table, - "nb_erreurs": cursor.fetchone()[0], - } - - -@router.get("/monitorings/{id_monito}/columns", tags=["Monitorings"]) -def get_monitoring_columns(id_monito: int): - """ - Retourne les colonnes de la table dédiée. - Permet au frontend de générer dynamiquement les en-têtes du tableau. - """ - table = get_table_name(id_monito) - with get_cursor() as cursor: - cursor.execute( - "SELECT COLUMN_NAME, DATA_TYPE " - "FROM INFORMATION_SCHEMA.COLUMNS " - "WHERE TABLE_NAME = ? ORDER BY ORDINAL_POSITION", - table - ) - return { - "id_monito": id_monito, - "table" : table, - "columns" : rows_to_list(cursor, cursor.fetchall()), - } - - -# ============================================================ -# DASHBOARD — VUE_CONSO -# ============================================================ - -@router.get("/dashboard", tags=["Dashboard"]) -def get_dashboard( - service : Optional[str] = Query(None, description="Filtrer par service"), - categorie : Optional[str] = Query(None, description="Filtrer par catégorie"), -): - """Vue consolidée — source principale du dashboard.""" - query = ( - "SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source " - "FROM VUE_CONSO WHERE 1=1" - ) - params = [] - - if service: - query += " AND service = ?" - params.append(service) - if categorie: - query += " AND categorie = ?" - params.append(categorie) - - query += " ORDER BY nb_erreurs DESC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/dashboard/filtres", tags=["Dashboard"]) -def get_dashboard_filtres(): - """ - Valeurs de filtre réellement présentes dans VUE_CONSO. - - Les référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles - aucun monitoring actif n'est rattaché (ex. « Business Intelligence »). - Les proposer dans les menus déroulants du dashboard mène à un écran vide : - cet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring. - - `combinaisons` permet au frontend de restreindre les catégories proposées - au service sélectionné. - """ - with get_cursor() as cursor: - cursor.execute( - "SELECT DISTINCT service, categorie FROM VUE_CONSO " - "ORDER BY service, categorie" - ) - combinaisons = [{"service": r[0], "categorie": r[1]} for r in cursor.fetchall()] - - return { - "services" : sorted({c["service"] for c in combinaisons}), - "categories" : sorted({c["categorie"] for c in combinaisons}), - "combinaisons" : combinaisons, - } - - -@router.get("/dashboard/summary", tags=["Dashboard"]) -def get_dashboard_summary(): - """KPI globaux pour les 4 cartes du dashboard.""" - with get_cursor() as cursor: - cursor.execute(""" - SELECT - COUNT(*) AS nb_monitorings, - SUM(nb_erreurs) AS total_erreurs, - MAX(nb_erreurs) AS max_erreurs, - SUM(CASE WHEN nb_erreurs = 0 THEN 1 ELSE 0 END) AS monitorings_ok, - SUM(CASE WHEN nb_erreurs > 0 THEN 1 ELSE 0 END) AS monitorings_en_erreur - FROM VUE_CONSO - """) - summary = row_to_dict(cursor, cursor.fetchone()) - - cursor.execute( - "SELECT TOP 1 id_monito, nom_monito, nb_erreurs, service, bdd_source " - "FROM VUE_CONSO ORDER BY nb_erreurs DESC" - ) - top = cursor.fetchone() - summary["monitoring_critique"] = row_to_dict(cursor, top) if top else None - - return summary - - -# ============================================================ -# HISTORIQUE — TABLE_FINAL -# ============================================================ - -@router.get("/historique", tags=["Historique"]) -def get_historique( - id_monito : Optional[int] = Query(None), - date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), - date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), - service : Optional[str] = Query(None), -): - """Snapshots journaliers (TABLE_FINAL).""" - query = ( - "SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source, " - "CONVERT(NVARCHAR, date_sauvegarde, 23) AS date_sauvegarde " - "FROM TABLE_FINAL WHERE 1=1" - ) - params = [] - - if id_monito: - query += " AND id_monito = ?" - params.append(id_monito) - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - if service: - query += " AND service = ?" - params.append(service) - - query += " ORDER BY date_sauvegarde ASC, id_monito ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/historique/{id_monito}/evolution", tags=["Historique"]) -def get_evolution( - id_monito : int, - date_debut : Optional[date] = Query(None), - date_fin : Optional[date] = Query(None), -): - """Évolution d'un monitoring — format optimisé Recharts.""" - get_table_name(id_monito) # valide l'existence du monitoring - - query = ( - "SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, nb_erreurs " - "FROM TABLE_FINAL WHERE id_monito = ?" - ) - params = [id_monito] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - - query += " ORDER BY date_sauvegarde ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()] - - return {"id_monito": id_monito, "points": len(data), "evolution": data} - - -@router.get("/historique/comparaison", tags=["Historique"]) -def get_comparaison( - date_debut : Optional[date] = Query(None), - date_fin : Optional[date] = Query(None), -): - """ - Déprécié — utiliser GET /evolution/par-monitoring à la place. - Conservé pour compatibilité ascendante. - """ - query = ( - "SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, " - "id_monito, nom_monito, nb_erreurs, service " - "FROM TABLE_FINAL WHERE 1=1" - ) - params = [] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - - query += " ORDER BY date_sauvegarde ASC, id_monito ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - return rows_to_list(cursor, cursor.fetchall()) - - -# ============================================================ -# ÉVOLUTION GLOBALE — VUE_TABLE_FINAL_CONSO -# Vue qui agrège TABLE_FINAL en 3 niveaux : -# GLOBAL → total toutes monitorings confondues (courbe principale) -# MONITO → détail par monitoring (courbes individuelles) -# SERVICE → regroupement par service (Contrat / Fournisseur) -# ============================================================ - -@router.get("/evolution/global", tags=["Évolution globale"]) -def get_evolution_global( - date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), - date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), -): - """ - Courbe globale : total des erreurs tous monitorings confondus, par jour. - Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL' - Format optimisé Recharts → [{date, nb_erreurs}] - """ - query = ( - "SELECT date_sauvegarde AS date, nb_erreurs " - "FROM VUE_TABLE_FINAL_CONSO " - "WHERE type_agregat = 'GLOBAL'" - ) - params = [] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - - query += " ORDER BY date_sauvegarde ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()] - - return { - "type" : "GLOBAL", - "points" : len(data), - "series" : data, - } - - -@router.get("/evolution/par-monitoring", tags=["Évolution globale"]) -def get_evolution_par_monitoring( - date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), - date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), - id_monito : Optional[int] = Query(None, description="Filtrer sur un seul monitoring"), -): - """ - Évolution par monitoring — toutes les courbes individuelles. - Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO' - - Retourne un format pivot par monitoring : - { - "monitorings": [{"id": 1, "nom": "...", "serie": [{"date","nb_erreurs"}]}] - } - Utile pour un graphique multi-lignes Recharts (une ligne par monitoring). - """ - query = ( - "SELECT date_sauvegarde AS date, id_monito, nom_monito, nb_erreurs " - "FROM VUE_TABLE_FINAL_CONSO " - "WHERE type_agregat = 'MONITO'" - ) - params = [] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - if id_monito: - query += " AND id_monito = ?" - params.append(id_monito) - - query += " ORDER BY id_monito ASC, date_sauvegarde ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - rows = cursor.fetchall() - - # Pivot : regrouper par monitoring - pivot: dict[int, dict] = {} - for row in rows: - date, mid, nom, nb = row[0], row[1], row[2], row[3] - if mid not in pivot: - pivot[mid] = {"id_monito": mid, "nom_monito": nom, "serie": []} - pivot[mid]["serie"].append({"date": date, "nb_erreurs": nb}) - - return { - "type" : "MONITO", - "nb_series" : len(pivot), - "monitorings" : list(pivot.values()), - } - - -@router.get("/evolution/par-service", tags=["Évolution globale"]) -def get_evolution_par_service( - date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), - date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), -): - """ - Évolution par service (Contrat / Fournisseur) — 1 courbe par service. - Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE' - - Retourne : - { - "services": [{"service": "Contrat", "serie": [{"date","nb_erreurs"}]}] - } - """ - query = ( - "SELECT date_sauvegarde AS date, nom_monito AS service, nb_erreurs " - "FROM VUE_TABLE_FINAL_CONSO " - "WHERE type_agregat = 'SERVICE'" - ) - params = [] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - - query += " ORDER BY nom_monito ASC, date_sauvegarde ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - rows = cursor.fetchall() - - # Pivot par service - pivot: dict[str, list] = {} - for row in rows: - date, service, nb = row[0], row[1], row[2] - if service not in pivot: - pivot[service] = [] - pivot[service].append({"date": date, "nb_erreurs": nb}) - - return { - "type" : "SERVICE", - "nb_series": len(pivot), - "services" : [{"service": s, "serie": v} for s, v in pivot.items()], - } - - -# ============================================================ -# SANTÉ +# SANTÉ — route publique # ============================================================ @app.get("/health", tags=["Système"]) @@ -655,175 +99,17 @@ def health_check(): # ============================================================ -# AUTHENTIFICATION +# Montage des routeurs +# +# Les routeurs de données déclarent eux-mêmes leur dépendance +# d'authentification. /health et /auth/login restent publics. # ============================================================ -class UserCreate(BaseModel): - username: str - email: str - password: str - role: str - -class UserUpdate(BaseModel): - email: Optional[str] = None - role: Optional[str] = None - actif: Optional[bool] = None - -class PasswordReset(BaseModel): - password: str - - -@app.post("/auth/login", tags=["Auth"]) -@limiter.limit("5/minute") -def login(request: Request, form: OAuth2PasswordRequestForm = Depends()): - """Authentifie un utilisateur et retourne un JWT.""" - with get_cursor() as cursor: - cursor.execute( - "SELECT id_user, username, password_hash, role, actif " - "FROM [USER] WHERE username = ?", form.username - ) - row = cursor.fetchone() - if not row or not row[4] or not verify_password(form.password, row[2]): - raise HTTPException(status_code=401, detail="Identifiants invalides") - ip = request.client.host if request.client else None - cursor.execute("UPDATE [USER] SET last_login = SYSDATETIME() WHERE id_user = ?", row[0]) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, ip) VALUES (?, ?, 'LOGIN', ?)", - row[0], row[1], ip - ) - token = create_access_token({"sub": row[1], "role": row[3], "uid": row[0]}) - return {"access_token": token, "token_type": "bearer", - "user": {"username": row[1], "role": row[3]}} - - -@app.get("/auth/me", tags=["Auth"]) -def me(user: dict = Depends(get_current_user)): - """Profil de l'utilisateur connecté.""" - return user - - -# ============================================================ -# ADMINISTRATION (réservé Admin) -# ============================================================ - -@app.get("/admin/users", tags=["Admin"]) -def list_users(admin: dict = Depends(require_admin)): - with get_cursor() as cursor: - cursor.execute( - "SELECT id_user, username, email, role, actif, created_at, last_login " - "FROM [USER] ORDER BY id_user" - ) - return rows_to_list(cursor, cursor.fetchall()) - - -@app.post("/admin/users", tags=["Admin"], status_code=201) -def create_user(body: UserCreate, admin: dict = Depends(require_admin)): - if body.role not in ("Admin", "Superviseur", "Consultant"): - raise HTTPException(status_code=400, detail="Rôle invalide") - with get_cursor() as cursor: - cursor.execute( - "INSERT INTO [USER] (username, email, password_hash, role) VALUES (?, ?, ?, ?)", - body.username, body.email, hash_password(body.password), body.role - ) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'CREATE_USER', ?)", - admin["id_user"], admin["username"], body.username - ) - return {"status": "created", "username": body.username} - - -@app.put("/admin/users/{id_user}", tags=["Admin"]) -def update_user(id_user: int, body: UserUpdate, admin: dict = Depends(require_admin)): - sets, params = [], [] - if body.email is not None: - sets.append("email = ?"); params.append(body.email) - if body.role is not None: - if body.role not in ("Admin", "Superviseur", "Consultant"): - raise HTTPException(status_code=400, detail="Rôle invalide") - sets.append("role = ?"); params.append(body.role) - if body.actif is not None: - sets.append("actif = ?"); params.append(1 if body.actif else 0) - if not sets: - raise HTTPException(status_code=400, detail="Aucun champ à modifier") - params.append(id_user) - with get_cursor() as cursor: - cursor.execute(f"UPDATE [USER] SET {', '.join(sets)} WHERE id_user = ?", *params) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'UPDATE_USER', ?)", - admin["id_user"], admin["username"], str(id_user) - ) - return {"status": "updated", "id_user": id_user} - - -@app.delete("/admin/users/{id_user}", tags=["Admin"]) -def delete_user(id_user: int, admin: dict = Depends(require_admin)): - """Suppression douce (actif = 0).""" - with get_cursor() as cursor: - cursor.execute("UPDATE [USER] SET actif = 0 WHERE id_user = ?", id_user) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'DELETE_USER', ?)", - admin["id_user"], admin["username"], str(id_user) - ) - return {"status": "deactivated", "id_user": id_user} - - -@app.post("/admin/users/{id_user}/reset-password", tags=["Admin"]) -def reset_password(id_user: int, body: PasswordReset, admin: dict = Depends(require_admin)): - with get_cursor() as cursor: - cursor.execute("UPDATE [USER] SET password_hash = ? WHERE id_user = ?", - hash_password(body.password), id_user) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'RESET_PASSWORD', ?)", - admin["id_user"], admin["username"], str(id_user) - ) - return {"status": "password_reset", "id_user": id_user} - - -@app.get("/admin/journal", tags=["Admin"]) -def get_journal(limit: int = 200, admin: dict = Depends(require_admin)): - with get_cursor() as cursor: - cursor.execute( - "SELECT TOP (?) date_action, username, action, detail, ip " - "FROM JOURNAL_AUDIT ORDER BY date_action DESC", limit - ) - return rows_to_list(cursor, cursor.fetchall()) - - -# ============================================================ -# RGPD — droits de la personne -# ============================================================ - -@app.get("/me/data-export", tags=["RGPD"]) -def export_my_data(user: dict = Depends(get_current_user)): - """Droit à la portabilité (art. 20) : export des données de l'utilisateur.""" - with get_cursor() as cursor: - cursor.execute( - "SELECT id_user, username, email, role, actif, created_at, last_login " - "FROM [USER] WHERE username = ?", user["username"] - ) - u = cursor.fetchone() - profile = row_to_dict(cursor, u) if u else {} - cursor.execute( - "SELECT date_action, action, detail, ip FROM JOURNAL_AUDIT " - "WHERE username = ? ORDER BY date_action DESC", user["username"] - ) - journal = rows_to_list(cursor, cursor.fetchall()) - return {"profile": profile, "journal": journal} - - -@app.delete("/me", tags=["RGPD"]) -def delete_my_account(user: dict = Depends(get_current_user)): - """Droit à l'oubli (art. 17) : désactivation + anonymisation.""" - with get_cursor() as cursor: - cursor.execute( - "UPDATE [USER] SET actif = 0, " - "username = CONCAT('deleted_', id_user), " - "email = CONCAT('deleted_', id_user, '@deleted.local') " - "WHERE username = ?", user["username"] - ) - cursor.execute("UPDATE JOURNAL_AUDIT SET username = NULL WHERE username = ?", user["username"]) - return {"status": "account_deleted"} - - -# Enregistre les routes "données" protégées par JWT. -app.include_router(router) +app.include_router(authentification.router) +app.include_router(referentiels.router) +app.include_router(monitorings.router, prefix="/monitorings") +app.include_router(dashboard.router, prefix="/dashboard") +app.include_router(historique.router, prefix="/historique") +app.include_router(evolution.router, prefix="/evolution") +app.include_router(admin.router) +app.include_router(rgpd.router) diff --git a/rate_limit.py b/rate_limit.py new file mode 100644 index 0000000..060e532 --- /dev/null +++ b/rate_limit.py @@ -0,0 +1,11 @@ +# ============================================================ +# rate_limit.py — Limiteur de débit partagé (anti brute-force) +# +# Isolé dans son propre module pour que les routeurs puissent +# décorer leurs routes sans importer main.py (import circulaire). +# ============================================================ + +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/admin.py b/routers/admin.py new file mode 100644 index 0000000..1615d3f --- /dev/null +++ b/routers/admin.py @@ -0,0 +1,104 @@ +# ============================================================ +# routers/admin.py — Gestion des comptes et journal d'audit +# +# Réservé au rôle Admin (403 sinon), via require_admin. +# ============================================================ + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel + +from auth import hash_password, require_admin +from config import get_cursor +from domain import AuditAction, UserRole +from helpers import journaliser, rows_to_list + +router = APIRouter(tags=["Admin"]) + + +class UserCreate(BaseModel): + username: str + email: str + password: str + role: UserRole + + +class UserUpdate(BaseModel): + email: Optional[str] = None + role: Optional[UserRole] = None + actif: Optional[bool] = None + + +class PasswordReset(BaseModel): + password: str + + +@router.get("/admin/users") +def list_users(admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "SELECT id_user, username, email, role, actif, created_at, last_login " + "FROM [USER] ORDER BY id_user" + ) + return rows_to_list(cursor, cursor.fetchall()) + + +@router.post("/admin/users", status_code=status.HTTP_201_CREATED) +def create_user(body: UserCreate, admin: dict = Depends(require_admin)): + # Le rôle est validé par Pydantic via l'enum UserRole (422 si invalide). + with get_cursor() as cursor: + cursor.execute( + "INSERT INTO [USER] (username, email, password_hash, role) VALUES (?, ?, ?, ?)", + body.username, body.email, hash_password(body.password), body.role.value + ) + journaliser(cursor, admin, AuditAction.CREATE_USER, body.username) + return {"status": "created", "username": body.username} + + +@router.put("/admin/users/{id_user}") +def update_user(id_user: int, body: UserUpdate, admin: dict = Depends(require_admin)): + sets, params = [], [] + if body.email is not None: + sets.append("email = ?"); params.append(body.email) + if body.role is not None: + sets.append("role = ?"); params.append(body.role.value) + if body.actif is not None: + sets.append("actif = ?"); params.append(1 if body.actif else 0) + if not sets: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Aucun champ à modifier" + ) + params.append(id_user) + with get_cursor() as cursor: + cursor.execute(f"UPDATE [USER] SET {', '.join(sets)} WHERE id_user = ?", *params) + journaliser(cursor, admin, AuditAction.UPDATE_USER, str(id_user)) + return {"status": "updated", "id_user": id_user} + + +@router.delete("/admin/users/{id_user}") +def delete_user(id_user: int, admin: dict = Depends(require_admin)): + """Suppression douce (actif = 0).""" + with get_cursor() as cursor: + cursor.execute("UPDATE [USER] SET actif = 0 WHERE id_user = ?", id_user) + journaliser(cursor, admin, AuditAction.DELETE_USER, str(id_user)) + return {"status": "deactivated", "id_user": id_user} + + +@router.post("/admin/users/{id_user}/reset-password") +def reset_password(id_user: int, body: PasswordReset, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute("UPDATE [USER] SET password_hash = ? WHERE id_user = ?", + hash_password(body.password), id_user) + journaliser(cursor, admin, AuditAction.RESET_PASSWORD, str(id_user)) + return {"status": "password_reset", "id_user": id_user} + + +@router.get("/admin/journal") +def get_journal(limit: int = 200, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "SELECT TOP (?) date_action, username, action, detail, ip " + "FROM JOURNAL_AUDIT ORDER BY date_action DESC", limit + ) + return rows_to_list(cursor, cursor.fetchall()) diff --git a/routers/authentification.py b/routers/authentification.py new file mode 100644 index 0000000..f5faf34 --- /dev/null +++ b/routers/authentification.py @@ -0,0 +1,47 @@ +# ============================================================ +# routers/authentification.py — Login JWT et profil courant +# +# Routes publiques (aucune dépendance d'authentification) : +# c'est ici qu'on obtient le jeton exigé par les autres routeurs. +# ============================================================ + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.security import OAuth2PasswordRequestForm + +from auth import create_access_token, get_current_user, verify_password +from config import get_cursor +from domain import AuditAction +from rate_limit import limiter + +router = APIRouter(tags=["Auth"]) + + +@router.post("/auth/login") +@limiter.limit("5/minute") +def login(request: Request, form: OAuth2PasswordRequestForm = Depends()): + """Authentifie un utilisateur et retourne un JWT.""" + with get_cursor() as cursor: + cursor.execute( + "SELECT id_user, username, password_hash, role, actif " + "FROM [USER] WHERE username = ?", form.username + ) + row = cursor.fetchone() + if not row or not row[4] or not verify_password(form.password, row[2]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Identifiants invalides" + ) + ip = request.client.host if request.client else None + cursor.execute("UPDATE [USER] SET last_login = SYSDATETIME() WHERE id_user = ?", row[0]) + cursor.execute( + "INSERT INTO JOURNAL_AUDIT (id_user, username, action, ip) VALUES (?, ?, ?, ?)", + row[0], row[1], AuditAction.LOGIN.value, ip + ) + token = create_access_token({"sub": row[1], "role": row[3], "uid": row[0]}) + return {"access_token": token, "token_type": "bearer", + "user": {"username": row[1], "role": row[3]}} + + +@router.get("/auth/me") +def me(user: dict = Depends(get_current_user)): + """Profil de l'utilisateur connecté.""" + return user diff --git a/routers/dashboard.py b/routers/dashboard.py new file mode 100644 index 0000000..5f3bb65 --- /dev/null +++ b/routers/dashboard.py @@ -0,0 +1,91 @@ +# ============================================================ +# routers/dashboard.py — Vue consolidée (VUE_CONSO) +# ============================================================ + +from typing import Optional + +from fastapi import APIRouter, Depends, Query + +from auth import get_current_user +from config import get_cursor +from helpers import row_to_dict, rows_to_list + +router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Dashboard"]) + + +@router.get("") +def get_dashboard( + service : Optional[str] = Query(None, description="Filtrer par service"), + categorie : Optional[str] = Query(None, description="Filtrer par catégorie"), +): + """Vue consolidée — source principale du dashboard.""" + query = ( + "SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source " + "FROM VUE_CONSO WHERE 1=1" + ) + params = [] + + if service: + query += " AND service = ?" + params.append(service) + if categorie: + query += " AND categorie = ?" + params.append(categorie) + + query += " ORDER BY nb_erreurs DESC" + + with get_cursor() as cursor: + cursor.execute(query, *params) + return rows_to_list(cursor, cursor.fetchall()) + + +@router.get("/filtres") +def get_dashboard_filtres(): + """ + Valeurs de filtre réellement présentes dans VUE_CONSO. + + Les référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles + aucun monitoring actif n'est rattaché (ex. « Business Intelligence »). + Les proposer dans les menus déroulants du dashboard mène à un écran vide : + cet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring. + + `combinaisons` permet au frontend de restreindre les catégories proposées + au service sélectionné. + """ + with get_cursor() as cursor: + cursor.execute( + "SELECT DISTINCT service, categorie FROM VUE_CONSO " + "ORDER BY service, categorie" + ) + combinaisons = [{"service": r[0], "categorie": r[1]} for r in cursor.fetchall()] + + return { + "services" : sorted({c["service"] for c in combinaisons}), + "categories" : sorted({c["categorie"] for c in combinaisons}), + "combinaisons" : combinaisons, + } + + +@router.get("/summary") +def get_dashboard_summary(): + """KPI globaux pour les 4 cartes du dashboard.""" + with get_cursor() as cursor: + cursor.execute(""" + SELECT + COUNT(*) AS nb_monitorings, + SUM(nb_erreurs) AS total_erreurs, + MAX(nb_erreurs) AS max_erreurs, + SUM(CASE WHEN nb_erreurs = 0 THEN 1 ELSE 0 END) AS monitorings_ok, + SUM(CASE WHEN nb_erreurs > 0 THEN 1 ELSE 0 END) AS monitorings_en_erreur + FROM VUE_CONSO + """) + summary = row_to_dict(cursor, cursor.fetchone()) + + cursor.execute( + "SELECT TOP 1 id_monito, nom_monito, nb_erreurs, service, bdd_source " + "FROM VUE_CONSO ORDER BY nb_erreurs DESC" + ) + top = cursor.fetchone() + summary["monitoring_critique"] = row_to_dict(cursor, top) if top else None + + return summary diff --git a/routers/evolution.py b/routers/evolution.py new file mode 100644 index 0000000..b7e3ff5 --- /dev/null +++ b/routers/evolution.py @@ -0,0 +1,158 @@ +# ============================================================ +# routers/evolution.py — Évolution globale (VUE_TABLE_FINAL_CONSO) +# +# Vue qui agrège TABLE_FINAL en 3 niveaux : +# GLOBAL → total toutes monitorings confondues (courbe principale) +# MONITO → détail par monitoring (courbes individuelles) +# SERVICE → regroupement par service (Contrat / Fournisseur) +# ============================================================ + +from datetime import date +from typing import Optional + +from fastapi import APIRouter, Depends, Query + +from auth import get_current_user +from config import get_cursor + +router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Évolution globale"]) + + +@router.get("/global") +def get_evolution_global( + date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), + date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), +): + """ + Courbe globale : total des erreurs tous monitorings confondus, par jour. + Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL' + Format optimisé Recharts → [{date, nb_erreurs}] + """ + query = ( + "SELECT date_sauvegarde AS date, nb_erreurs " + "FROM VUE_TABLE_FINAL_CONSO " + "WHERE type_agregat = 'GLOBAL'" + ) + params = [] + + if date_debut: + query += " AND date_sauvegarde >= ?" + params.append(str(date_debut)) + if date_fin: + query += " AND date_sauvegarde <= ?" + params.append(str(date_fin)) + + query += " ORDER BY date_sauvegarde ASC" + + with get_cursor() as cursor: + cursor.execute(query, *params) + data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()] + + return { + "type" : "GLOBAL", + "points" : len(data), + "series" : data, + } + + +@router.get("/par-monitoring") +def get_evolution_par_monitoring( + date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), + date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), + id_monito : Optional[int] = Query(None, description="Filtrer sur un seul monitoring"), +): + """ + Évolution par monitoring — toutes les courbes individuelles. + Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO' + + Retourne un format pivot par monitoring : + { + "monitorings": [{"id": 1, "nom": "...", "serie": [{"date","nb_erreurs"}]}] + } + Utile pour un graphique multi-lignes Recharts (une ligne par monitoring). + """ + query = ( + "SELECT date_sauvegarde AS date, id_monito, nom_monito, nb_erreurs " + "FROM VUE_TABLE_FINAL_CONSO " + "WHERE type_agregat = 'MONITO'" + ) + params = [] + + if date_debut: + query += " AND date_sauvegarde >= ?" + params.append(str(date_debut)) + if date_fin: + query += " AND date_sauvegarde <= ?" + params.append(str(date_fin)) + if id_monito: + query += " AND id_monito = ?" + params.append(id_monito) + + query += " ORDER BY id_monito ASC, date_sauvegarde ASC" + + with get_cursor() as cursor: + cursor.execute(query, *params) + rows = cursor.fetchall() + + # Pivot : regrouper par monitoring + pivot: dict[int, dict] = {} + for row in rows: + date_point, mid, nom, nb = row[0], row[1], row[2], row[3] + if mid not in pivot: + pivot[mid] = {"id_monito": mid, "nom_monito": nom, "serie": []} + pivot[mid]["serie"].append({"date": date_point, "nb_erreurs": nb}) + + return { + "type" : "MONITO", + "nb_series" : len(pivot), + "monitorings" : list(pivot.values()), + } + + +@router.get("/par-service") +def get_evolution_par_service( + date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), + date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), +): + """ + Évolution par service (Contrat / Fournisseur) — 1 courbe par service. + Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE' + + Retourne : + { + "services": [{"service": "Contrat", "serie": [{"date","nb_erreurs"}]}] + } + """ + query = ( + "SELECT date_sauvegarde AS date, nom_monito AS service, nb_erreurs " + "FROM VUE_TABLE_FINAL_CONSO " + "WHERE type_agregat = 'SERVICE'" + ) + params = [] + + if date_debut: + query += " AND date_sauvegarde >= ?" + params.append(str(date_debut)) + if date_fin: + query += " AND date_sauvegarde <= ?" + params.append(str(date_fin)) + + query += " ORDER BY nom_monito ASC, date_sauvegarde ASC" + + with get_cursor() as cursor: + cursor.execute(query, *params) + rows = cursor.fetchall() + + # Pivot par service + pivot: dict[str, list] = {} + for row in rows: + date_point, service, nb = row[0], row[1], row[2] + if service not in pivot: + pivot[service] = [] + pivot[service].append({"date": date_point, "nb_erreurs": nb}) + + return { + "type" : "SERVICE", + "nb_series": len(pivot), + "services" : [{"service": s, "serie": v} for s, v in pivot.items()], + } diff --git a/routers/historique.py b/routers/historique.py new file mode 100644 index 0000000..1a8831a --- /dev/null +++ b/routers/historique.py @@ -0,0 +1,110 @@ +# ============================================================ +# routers/historique.py — Snapshots journaliers (TABLE_FINAL) +# ============================================================ + +from datetime import date +from typing import Optional + +from fastapi import APIRouter, Depends, Query + +from auth import get_current_user +from config import get_cursor +from helpers import get_table_name, rows_to_list + +router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Historique"]) + + +@router.get("") +def get_historique( + id_monito : Optional[int] = Query(None), + date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), + date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), + service : Optional[str] = Query(None), +): + """Snapshots journaliers (TABLE_FINAL).""" + query = ( + "SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source, " + "CONVERT(NVARCHAR, date_sauvegarde, 23) AS date_sauvegarde " + "FROM TABLE_FINAL WHERE 1=1" + ) + params = [] + + if id_monito: + query += " AND id_monito = ?" + params.append(id_monito) + if date_debut: + query += " AND date_sauvegarde >= ?" + params.append(str(date_debut)) + if date_fin: + query += " AND date_sauvegarde <= ?" + params.append(str(date_fin)) + if service: + query += " AND service = ?" + params.append(service) + + query += " ORDER BY date_sauvegarde ASC, id_monito ASC" + + with get_cursor() as cursor: + cursor.execute(query, *params) + return rows_to_list(cursor, cursor.fetchall()) + + +@router.get("/comparaison") +def get_comparaison( + date_debut : Optional[date] = Query(None), + date_fin : Optional[date] = Query(None), +): + """ + Déprécié — utiliser GET /evolution/par-monitoring à la place. + Conservé pour compatibilité ascendante. + """ + query = ( + "SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, " + "id_monito, nom_monito, nb_erreurs, service " + "FROM TABLE_FINAL WHERE 1=1" + ) + params = [] + + if date_debut: + query += " AND date_sauvegarde >= ?" + params.append(str(date_debut)) + if date_fin: + query += " AND date_sauvegarde <= ?" + params.append(str(date_fin)) + + query += " ORDER BY date_sauvegarde ASC, id_monito ASC" + + with get_cursor() as cursor: + cursor.execute(query, *params) + return rows_to_list(cursor, cursor.fetchall()) + + +@router.get("/{id_monito}/evolution") +def get_evolution( + id_monito : int, + date_debut : Optional[date] = Query(None), + date_fin : Optional[date] = Query(None), +): + """Évolution d'un monitoring — format optimisé Recharts.""" + get_table_name(id_monito) # valide l'existence du monitoring + + query = ( + "SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, nb_erreurs " + "FROM TABLE_FINAL WHERE id_monito = ?" + ) + params = [id_monito] + + if date_debut: + query += " AND date_sauvegarde >= ?" + params.append(str(date_debut)) + if date_fin: + query += " AND date_sauvegarde <= ?" + params.append(str(date_fin)) + + query += " ORDER BY date_sauvegarde ASC" + + with get_cursor() as cursor: + cursor.execute(query, *params) + data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()] + + return {"id_monito": id_monito, "points": len(data), "evolution": data} diff --git a/routers/monitorings.py b/routers/monitorings.py new file mode 100644 index 0000000..b4327bd --- /dev/null +++ b/routers/monitorings.py @@ -0,0 +1,215 @@ +# ============================================================ +# routers/monitorings.py — Nomenclature et données détaillées +# +# Chaque monitoring dispose d'une table MONITO_[NOM] dédiée, +# résolue via le mapping MONITO_TABLES (domain.py). +# ============================================================ + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field + +from auth import get_current_user, require_admin +from config import get_cursor +from domain import AuditAction, SEARCH_COLS +from helpers import get_table_name, journaliser, row_to_dict, rows_to_list, serialize_row + +router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Monitorings"]) + + +class MonitoringBody(BaseModel): + monito_intitule: str = Field(min_length=1, max_length=200) + id_service: int + id_categorie: int + table_source: str = Field(min_length=1, max_length=100) + bdd_source: str = Field(min_length=1, max_length=50) + + +class MonitoringUpdate(BaseModel): + monito_intitule: Optional[str] = Field(default=None, min_length=1, max_length=200) + id_service: Optional[int] = None + id_categorie: Optional[int] = None + table_source: Optional[str] = Field(default=None, min_length=1, max_length=100) + bdd_source: Optional[str] = Field(default=None, min_length=1, max_length=50) + actif: Optional[bool] = None + + +@router.get("") +def get_monitorings( + id_service : Optional[int] = Query(None, description="Filtrer par service"), + id_categorie : Optional[int] = Query(None, description="Filtrer par catégorie"), +): + """Liste des monitorings actifs avec table_source et bdd_source.""" + query = ( + "SELECT id_monito, monito_intitule, id_service, id_categorie, " + "table_source, bdd_source " + "FROM NOMENCLATURE_MONITO WHERE actif = 1" + ) + params = [] + + if id_service: + query += " AND id_service = ?" + params.append(id_service) + if id_categorie: + query += " AND id_categorie = ?" + params.append(id_categorie) + + query += " ORDER BY id_monito" + + with get_cursor() as cursor: + cursor.execute(query, *params) + return rows_to_list(cursor, cursor.fetchall()) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_monitoring(body: MonitoringBody, admin: dict = Depends(require_admin)): + """ + Déclare un monitoring dans la nomenclature. + + Attention : cela n'ajoute que l'entrée de référentiel. Pour qu'il remonte + des données, il faut aussi créer la table MONITO_[NOM] correspondante et + l'ajouter au mapping MONITO_TABLES — voir l'en-tête de data_sentinel_init.sql. + """ + with get_cursor() as cursor: + cursor.execute( + "INSERT INTO NOMENCLATURE_MONITO " + "(monito_intitule, id_service, id_categorie, table_source, bdd_source, actif) " + "OUTPUT INSERTED.id_monito VALUES (?, ?, ?, ?, ?, 1)", + body.monito_intitule, body.id_service, body.id_categorie, + body.table_source, body.bdd_source + ) + id_monito = cursor.fetchone()[0] + journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL, + f"NOMENCLATURE_MONITO {id_monito} : {body.monito_intitule}") + return {"id_monito": id_monito, **body.model_dump()} + + +@router.put("/{id_monito}") +def update_monitoring(id_monito: int, body: MonitoringUpdate, admin: dict = Depends(require_admin)): + champs = { + "monito_intitule": body.monito_intitule, + "id_service": body.id_service, + "id_categorie": body.id_categorie, + "table_source": body.table_source, + "bdd_source": body.bdd_source, + "actif": None if body.actif is None else int(body.actif), + } + modifications = {col: val for col, val in champs.items() if val is not None} + if not modifications: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Aucun champ à modifier") + + affectations = ", ".join(f"{col} = ?" for col in modifications) + params = [*modifications.values(), id_monito] + + with get_cursor() as cursor: + cursor.execute( + f"UPDATE NOMENCLATURE_MONITO SET {affectations} WHERE id_monito = ?", *params + ) + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Monitoring {id_monito} introuvable.") + journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL, + f"NOMENCLATURE_MONITO {id_monito}") + return {"status": "updated", "id_monito": id_monito} + + +@router.delete("/{id_monito}") +def desactiver_monitoring(id_monito: int, admin: dict = Depends(require_admin)): + """ + Désactivation (actif = 0), pas de suppression physique : TABLE_FINAL + référence id_monito et l'historique doit rester consultable. + """ + with get_cursor() as cursor: + cursor.execute("UPDATE NOMENCLATURE_MONITO SET actif = 0 WHERE id_monito = ?", id_monito) + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Monitoring {id_monito} introuvable.") + journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, + f"NOMENCLATURE_MONITO {id_monito} désactivé") + return {"status": "deactivated", "id_monito": id_monito} + + +@router.get("/{id_monito}") +def get_monitoring_by_id(id_monito: int): + """Détail d'un monitoring.""" + with get_cursor() as cursor: + cursor.execute( + "SELECT id_monito, monito_intitule, id_service, id_categorie, " + "table_source, bdd_source " + "FROM NOMENCLATURE_MONITO WHERE id_monito = ? AND actif = 1", + id_monito + ) + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Monitoring {id_monito} introuvable.") + return row_to_dict(cursor, row) + + +@router.get("/{id_monito}/details") +def get_monitoring_details( + id_monito : int, + search : Optional[str] = Query( + None, + description="Recherche sur colonnes texte (ct_intitule, agence, STE...)" + ), + limit : int = Query(500, ge=1, le=5000, description="Lignes max — mettre 5000 pour export CSV complet"), + offset : int = Query(0, ge=0, description="Offset pagination"), +): + """ + Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée. + Toutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend. + """ + table = get_table_name(id_monito) + query = f"SELECT * FROM {table} WHERE 1=1" + params = [] + + if search and table in SEARCH_COLS: + cols = SEARCH_COLS[table] + conditions = " OR ".join([f"{col} LIKE ?" for col in cols]) + query += f" AND ({conditions})" + params.extend([f"%{search}%"] * len(cols)) + + query += " ORDER BY date_extraction DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" + params += [offset, limit] + + with get_cursor() as cursor: + cursor.execute(query, *params) + rows = rows_to_list(cursor, cursor.fetchall()) + return [serialize_row(r) for r in rows] + + +@router.get("/{id_monito}/count") +def get_monitoring_count(id_monito: int): + """Nombre d'erreurs dans la table dédiée du monitoring.""" + table = get_table_name(id_monito) + with get_cursor() as cursor: + cursor.execute(f"SELECT COUNT(*) FROM {table}") + return { + "id_monito" : id_monito, + "table" : table, + "nb_erreurs": cursor.fetchone()[0], + } + + +@router.get("/{id_monito}/columns") +def get_monitoring_columns(id_monito: int): + """ + Retourne les colonnes de la table dédiée. + Permet au frontend de générer dynamiquement les en-têtes du tableau. + """ + table = get_table_name(id_monito) + with get_cursor() as cursor: + cursor.execute( + "SELECT COLUMN_NAME, DATA_TYPE " + "FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_NAME = ? ORDER BY ORDINAL_POSITION", + table + ) + return { + "id_monito": id_monito, + "table" : table, + "columns" : rows_to_list(cursor, cursor.fetchall()), + } diff --git a/routers/referentiels.py b/routers/referentiels.py new file mode 100644 index 0000000..31f17bb --- /dev/null +++ b/routers/referentiels.py @@ -0,0 +1,233 @@ +# ============================================================ +# routers/referentiels.py — Services, catégories et contacts +# +# Lecture ouverte à tout utilisateur authentifié ; création, +# modification et suppression réservées aux administrateurs. +# ============================================================ + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field + +from auth import get_current_user, require_admin +from config import get_cursor +from domain import AuditAction +from helpers import journaliser, row_to_dict, rows_to_list + +router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Référentiels"]) + + +class ServiceBody(BaseModel): + nom_service: str = Field(min_length=1, max_length=100) + + +class CategorieBody(BaseModel): + intitule_categorie: str = Field(min_length=1, max_length=100) + + +class ContactBody(BaseModel): + id_service: int + intitule_contact: str = Field(min_length=1, max_length=100) + nom: str = Field(min_length=1, max_length=100) + prenom: str = Field(min_length=1, max_length=100) + mail: str = Field(min_length=1, max_length=200) + + +# ------------------------------------------------------------ +# Catégories +# ------------------------------------------------------------ + +@router.get("/categories") +def get_categories(): + """Toutes les catégories de monitoring.""" + with get_cursor() as cursor: + cursor.execute( + "SELECT id_categorie, intitule_categorie " + "FROM CATEGORIE ORDER BY intitule_categorie" + ) + return rows_to_list(cursor, cursor.fetchall()) + + +@router.post("/categories", status_code=status.HTTP_201_CREATED) +def create_categorie(body: CategorieBody, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "INSERT INTO CATEGORIE (intitule_categorie) OUTPUT INSERTED.id_categorie VALUES (?)", + body.intitule_categorie + ) + id_categorie = cursor.fetchone()[0] + journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL, + f"CATEGORIE {id_categorie} : {body.intitule_categorie}") + return {"id_categorie": id_categorie, "intitule_categorie": body.intitule_categorie} + + +@router.put("/categories/{id_categorie}") +def update_categorie(id_categorie: int, body: CategorieBody, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "UPDATE CATEGORIE SET intitule_categorie = ? WHERE id_categorie = ?", + body.intitule_categorie, id_categorie + ) + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Catégorie {id_categorie} introuvable.") + journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL, + f"CATEGORIE {id_categorie} : {body.intitule_categorie}") + return {"id_categorie": id_categorie, "intitule_categorie": body.intitule_categorie} + + +@router.delete("/categories/{id_categorie}") +def delete_categorie(id_categorie: int, admin: dict = Depends(require_admin)): + """ + Supprime une catégorie, sauf si des monitorings s'y rattachent encore : + la clé étrangère FK_NOM_CATEGORIE l'interdirait, autant renvoyer un + message explicite plutôt que de laisser remonter l'erreur SQL brute. + """ + with get_cursor() as cursor: + cursor.execute( + "SELECT COUNT(*) FROM NOMENCLATURE_MONITO WHERE id_categorie = ?", id_categorie + ) + nb_rattaches = cursor.fetchone()[0] + if nb_rattaches: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Suppression impossible : {nb_rattaches} monitoring(s) utilisent cette catégorie." + ) + cursor.execute("DELETE FROM CATEGORIE WHERE id_categorie = ?", id_categorie) + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Catégorie {id_categorie} introuvable.") + journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"CATEGORIE {id_categorie}") + return {"status": "deleted", "id_categorie": id_categorie} + + +# ------------------------------------------------------------ +# Services +# ------------------------------------------------------------ + +@router.get("/services") +def get_services(): + """Tous les services.""" + with get_cursor() as cursor: + cursor.execute("SELECT id_service, nom_service FROM SERVICE ORDER BY nom_service") + return rows_to_list(cursor, cursor.fetchall()) + + +@router.post("/services", status_code=status.HTTP_201_CREATED) +def create_service(body: ServiceBody, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "INSERT INTO SERVICE (nom_service) OUTPUT INSERTED.id_service VALUES (?)", + body.nom_service + ) + id_service = cursor.fetchone()[0] + journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL, + f"SERVICE {id_service} : {body.nom_service}") + return {"id_service": id_service, "nom_service": body.nom_service} + + +@router.put("/services/{id_service}") +def update_service(id_service: int, body: ServiceBody, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "UPDATE SERVICE SET nom_service = ? WHERE id_service = ?", + body.nom_service, id_service + ) + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Service {id_service} introuvable.") + journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL, + f"SERVICE {id_service} : {body.nom_service}") + return {"id_service": id_service, "nom_service": body.nom_service} + + +@router.delete("/services/{id_service}") +def delete_service(id_service: int, admin: dict = Depends(require_admin)): + """Refuse la suppression tant que des monitorings ou contacts y sont rattachés.""" + with get_cursor() as cursor: + cursor.execute( + "SELECT " + "(SELECT COUNT(*) FROM NOMENCLATURE_MONITO WHERE id_service = ?), " + "(SELECT COUNT(*) FROM CONTACT WHERE id_service = ?)", + id_service, id_service + ) + nb_monitorings, nb_contacts = cursor.fetchone() + if nb_monitorings or nb_contacts: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=(f"Suppression impossible : {nb_monitorings} monitoring(s) " + f"et {nb_contacts} contact(s) rattachés à ce service.") + ) + cursor.execute("DELETE FROM SERVICE WHERE id_service = ?", id_service) + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Service {id_service} introuvable.") + journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"SERVICE {id_service}") + return {"status": "deleted", "id_service": id_service} + + +# ------------------------------------------------------------ +# Contacts +# ------------------------------------------------------------ + +@router.get("/contacts") +def get_contacts( + id_service: Optional[int] = Query(None, description="Filtrer par service") +): + """Contacts, filtrables par service.""" + with get_cursor() as cursor: + if id_service: + cursor.execute( + "SELECT id_contact, id_service, intitule_contact, nom, prenom, mail " + "FROM CONTACT WHERE id_service = ? ORDER BY nom", + id_service + ) + else: + cursor.execute( + "SELECT id_contact, id_service, intitule_contact, nom, prenom, mail " + "FROM CONTACT ORDER BY nom" + ) + return rows_to_list(cursor, cursor.fetchall()) + + +@router.post("/contacts", status_code=status.HTTP_201_CREATED) +def create_contact(body: ContactBody, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "INSERT INTO CONTACT (id_service, intitule_contact, nom, prenom, mail) " + "OUTPUT INSERTED.id_contact VALUES (?, ?, ?, ?, ?)", + body.id_service, body.intitule_contact, body.nom, body.prenom, body.mail + ) + id_contact = cursor.fetchone()[0] + journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL, + f"CONTACT {id_contact} : {body.nom} {body.prenom}") + return {"id_contact": id_contact, **body.model_dump()} + + +@router.put("/contacts/{id_contact}") +def update_contact(id_contact: int, body: ContactBody, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "UPDATE CONTACT SET id_service = ?, intitule_contact = ?, nom = ?, prenom = ?, mail = ? " + "WHERE id_contact = ?", + body.id_service, body.intitule_contact, body.nom, body.prenom, body.mail, id_contact + ) + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Contact {id_contact} introuvable.") + journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL, + f"CONTACT {id_contact} : {body.nom} {body.prenom}") + return {"id_contact": id_contact, **body.model_dump()} + + +@router.delete("/contacts/{id_contact}") +def delete_contact(id_contact: int, admin: dict = Depends(require_admin)): + """Un contact n'est référencé par aucune autre table : suppression directe.""" + with get_cursor() as cursor: + cursor.execute("DELETE FROM CONTACT WHERE id_contact = ?", id_contact) + if cursor.rowcount == 0: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Contact {id_contact} introuvable.") + journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"CONTACT {id_contact}") + return {"status": "deleted", "id_contact": id_contact} diff --git a/routers/rgpd.py b/routers/rgpd.py new file mode 100644 index 0000000..d48db4c --- /dev/null +++ b/routers/rgpd.py @@ -0,0 +1,43 @@ +# ============================================================ +# routers/rgpd.py — Droits de la personne concernée (RGPD) +# ============================================================ + +from fastapi import APIRouter, Depends + +from auth import get_current_user +from config import get_cursor +from helpers import row_to_dict, rows_to_list + +router = APIRouter(tags=["RGPD"]) + + +@router.get("/me/data-export") +def export_my_data(user: dict = Depends(get_current_user)): + """Droit à la portabilité (art. 20) : export des données de l'utilisateur.""" + with get_cursor() as cursor: + cursor.execute( + "SELECT id_user, username, email, role, actif, created_at, last_login " + "FROM [USER] WHERE username = ?", user["username"] + ) + u = cursor.fetchone() + profile = row_to_dict(cursor, u) if u else {} + cursor.execute( + "SELECT date_action, action, detail, ip FROM JOURNAL_AUDIT " + "WHERE username = ? ORDER BY date_action DESC", user["username"] + ) + journal = rows_to_list(cursor, cursor.fetchall()) + return {"profile": profile, "journal": journal} + + +@router.delete("/me") +def delete_my_account(user: dict = Depends(get_current_user)): + """Droit à l'oubli (art. 17) : désactivation + anonymisation.""" + with get_cursor() as cursor: + cursor.execute( + "UPDATE [USER] SET actif = 0, " + "username = CONCAT('deleted_', id_user), " + "email = CONCAT('deleted_', id_user, '@deleted.local') " + "WHERE username = ?", user["username"] + ) + cursor.execute("UPDATE JOURNAL_AUDIT SET username = NULL WHERE username = ?", user["username"]) + return {"status": "account_deleted"} diff --git a/tests/conftest.py b/tests/conftest.py index 3b54a3f..1e34eb5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,23 @@ from fastapi.testclient import TestClient import main from auth import create_access_token +from routers import ( + admin, + authentification, + dashboard, + evolution, + historique, + monitorings, + referentiels, + rgpd, +) + +# Modules qui ont fait `from config import get_cursor` : l'import lie le nom +# dans leur propre espace de noms, il faut donc le remplacer dans chacun. +MODULES_AVEC_CURSEUR = ( + main, admin, authentification, dashboard, evolution, + historique, monitorings, referentiels, rgpd, +) class FakeCursor: @@ -17,6 +34,7 @@ class FakeCursor: self.description = [] self._rows = [] self._one = None + self.rowcount = 1 self.executed = [] def execute(self, query, *args): @@ -38,7 +56,9 @@ def cur(monkeypatch): def fake_get_cursor(): yield c - monkeypatch.setattr(main, "get_cursor", fake_get_cursor) + for module in MODULES_AVEC_CURSEUR: + if hasattr(module, "get_cursor"): + monkeypatch.setattr(module, "get_cursor", fake_get_cursor) return c From e7b3944436d147c23bd1f7fffa0f01030fa79be7 Mon Sep 17 00:00:00 2001 From: Anthony Coyaud Date: Sat, 15 Aug 2026 14:28:58 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(api):=20CRUD=20des=20r=C3=A9f=C3=A9ren?= =?UTF-8?q?tiels=20r=C3=A9serv=C3=A9=20aux=20administrateurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'écran d'administration du front proposait des boutons Modifier et Désactiver qui n'appelaient rien (console.log côté client), faute d'endpoints correspondants. Ajoute POST/PUT/DELETE sur /services, /categories, /contacts et /monitorings, tous protégés par require_admin. Deux garde-fous métier : - la suppression d'un service ou d'une catégorie est refusée (409) tant que des monitorings ou contacts y sont rattachés, avec le décompte dans le message, plutôt que de laisser remonter une violation de clé étrangère ; - un monitoring est désactivé (actif = 0) et jamais supprimé, car TABLE_FINAL référence son identifiant et l'historique doit rester consultable. Le rôle utilisateur est désormais typé par l'enum UserRole : Pydantic le valide seul (422), ce qui supprime les deux contrôles manuels dupliqués dans create_user et update_user. Tests : 8 -> 16. Couvre la validation par enum, le refus 409 sur rattachement, le 403 pour un non-administrateur et la désactivation logique du monitoring. --- README.md | 36 +++++++++++++++++++---- docs/openapi.json | 2 +- tests/test_endpoints.py | 64 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3f2710b..8b8d1b7 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,17 @@ python -m uvicorn main:app --reload --port 8000 - Swagger : `http://127.0.0.1:8000/docs` · Santé : `http://127.0.0.1:8000/health` Sans variables d'environnement, l'API se connecte en authentification Windows -(`LaptopCA\SQLEXPRESS`) — comportement de dev d'origine, inchangé. +sur `localhost`. Pour une instance nommée, définir `DB_SERVER` (ex. +`DB_SERVER=MonPoste\SQLEXPRESS`, sans `DB_PORT`). ## Configuration (variables d'environnement) | Variable | Rôle | Défaut | |----------|------|--------| -| `DB_SERVER` | hôte SQL Server (active l'auth SQL si défini) | — (sinon Windows local) | -| `DB_PORT` / `DB_NAME` | port / base | `1433` / `DataSentinel` | -| `DB_USER` / `DB_PASSWORD` | compte applicatif | — | +| `DB_SERVER` | hôte SQL Server (ou `hôte\instance`) | `localhost` | +| `DB_PORT` / `DB_NAME` | port / base — laisser `DB_PORT` vide pour une instance nommée | — / `DataSentinel` | +| `DB_USER` / `DB_PASSWORD` | compte applicatif ; sa présence active l'authentification SQL | — (sinon auth Windows) | +| `DB_TRUSTED_CONNECTION` | force l'authentification Windows même si `DB_USER` est défini | — | | `DB_DRIVER` | pilote ODBC | `ODBC Driver 18 for SQL Server` | | `CORS_ORIGINS` | origines autorisées (séparées par `,`) | `localhost:5173,localhost:3000` | | `JWT_SECRET` | clé de signature JWT | placeholder (à définir en prod) | @@ -62,10 +64,32 @@ Les mots de passe ci-dessus sont des identifiants de démonstration : ils sont stockés hachés (bcrypt, coût 12) et doivent être régénérés avant toute mise en production réelle (`POST /admin/users/{id}/reset-password`). +## Organisation du code + +`main.py` ne fait que l'assemblage (configuration, middlewares, montage des +routeurs). Chaque domaine fonctionnel vit dans `routers/` : + +| Module | Responsabilité | +|--------|----------------| +| `routers/authentification.py` | `/auth/login` (rate-limité), `/auth/me` | +| `routers/referentiels.py` | services, catégories, contacts — lecture + CRUD Admin | +| `routers/monitorings.py` | nomenclature + données détaillées par table dédiée | +| `routers/dashboard.py` | `VUE_CONSO` : vue consolidée, filtres, KPI | +| `routers/historique.py` | snapshots journaliers (`TABLE_FINAL`) | +| `routers/evolution.py` | `VUE_TABLE_FINAL_CONSO` : courbes global / monitoring / service | +| `routers/admin.py` | comptes utilisateurs et journal d'audit | +| `routers/rgpd.py` | portabilité et droit à l'oubli | +| `domain.py` | enums (`UserRole`, `AuditAction`) et mapping `MONITO_TABLES` | +| `helpers.py` | conversion des lignes pyodbc, écriture du journal d'audit | + ## Endpoints (résumé) - **Données** (protégés) : `/categories`, `/services`, `/contacts`, `/monitorings[...]`, `/dashboard[...]`, `/historique[...]`, `/evolution/*`. +- **Référentiels** (`Admin` en écriture) : `POST/PUT/DELETE` sur `/services`, + `/categories`, `/contacts` et `/monitorings`. La suppression est refusée (409) + tant que des enregistrements y sont rattachés ; un monitoring est **désactivé** + (`actif = 0`) et jamais supprimé, car `TABLE_FINAL` référence son identifiant. - **Admin** (`Admin`) : `GET/POST /admin/users`, `PUT/DELETE /admin/users/{id}`, `POST /admin/users/{id}/reset-password`, `GET /admin/journal`. - **RGPD** : `GET /me/data-export` (portabilité), `DELETE /me` (droit à l'oubli + anonymisation). @@ -86,7 +110,9 @@ Spécification complète : `GET /openapi.json` (export dans `docs/openapi.json`) ```bash pip install pytest httpx && pytest -q ``` -Curseur SQL simulé (aucune vraie BDD) : santé, auth (succès/échec), protection 401, RBAC 403, 404. +Curseur SQL simulé (aucune vraie BDD) : santé, auth (succès/échec), protection 401, +RBAC 403, 404, validation des rôles par enum (422), refus de suppression d'un +référentiel rattaché (409), désactivation logique d'un monitoring. ## Docker & CI/CD diff --git a/docs/openapi.json b/docs/openapi.json index a1a103f..bd19a86 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Data Sentinel API","description":"API de monitoring de la qualité des données — XEFI","version":"1.0.0"},"paths":{"/health":{"get":{"tags":["Système"],"summary":"Health Check","description":"Ping API + test connexion SQL Server.","operationId":"health_check_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/auth/login":{"post":{"tags":["Auth"],"summary":"Login","description":"Authentifie un utilisateur et retourne un JWT.","operationId":"login_auth_login_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_login_auth_login_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/me":{"get":{"tags":["Auth"],"summary":"Me","description":"Profil de l'utilisateur connecté.","operationId":"me_auth_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/admin/users":{"get":{"tags":["Admin"],"summary":"List Users","operationId":"list_users_admin_users_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["Admin"],"summary":"Create User","operationId":"create_user_admin_users_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/admin/users/{id_user}":{"put":{"tags":["Admin"],"summary":"Update User","operationId":"update_user_admin_users__id_user__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Admin"],"summary":"Delete User","description":"Suppression douce (actif = 0).","operationId":"delete_user_admin_users__id_user__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/users/{id_user}/reset-password":{"post":{"tags":["Admin"],"summary":"Reset Password","operationId":"reset_password_admin_users__id_user__reset_password_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordReset"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/journal":{"get":{"tags":["Admin"],"summary":"Get Journal","operationId":"get_journal_admin_journal_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":200,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/me/data-export":{"get":{"tags":["RGPD"],"summary":"Export My Data","description":"Droit à la portabilité (art. 20) : export des données de l'utilisateur.","operationId":"export_my_data_me_data_export_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/me":{"delete":{"tags":["RGPD"],"summary":"Delete My Account","description":"Droit à l'oubli (art. 17) : désactivation + anonymisation.","operationId":"delete_my_account_me_delete","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/categories":{"get":{"tags":["Référentiels"],"summary":"Get Categories","description":"Toutes les catégories de monitoring.","operationId":"get_categories_categories_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/services":{"get":{"tags":["Référentiels"],"summary":"Get Services","description":"Tous les services.","operationId":"get_services_services_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/contacts":{"get":{"tags":["Référentiels"],"summary":"Get Contacts","description":"Contacts, filtrables par service.","operationId":"get_contacts_contacts_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par service","title":"Id Service"},"description":"Filtrer par service"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings":{"get":{"tags":["Monitorings"],"summary":"Get Monitorings","description":"Liste des monitorings actifs avec table_source et bdd_source.","operationId":"get_monitorings_monitorings_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par service","title":"Id Service"},"description":"Filtrer par service"},{"name":"id_categorie","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par catégorie","title":"Id Categorie"},"description":"Filtrer par catégorie"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring By Id","description":"Détail d'un monitoring.","operationId":"get_monitoring_by_id_monitorings__id_monito__get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/details":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Details","description":"Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée.\nToutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend.","operationId":"get_monitoring_details_monitorings__id_monito__details_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}},{"name":"search","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Recherche sur colonnes texte (ct_intitule, agence, STE...)","title":"Search"},"description":"Recherche sur colonnes texte (ct_intitule, agence, STE...)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":5000,"minimum":1,"description":"Lignes max — mettre 5000 pour export CSV complet","default":500,"title":"Limit"},"description":"Lignes max — mettre 5000 pour export CSV complet"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Offset pagination","default":0,"title":"Offset"},"description":"Offset pagination"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/count":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Count","description":"Nombre d'erreurs dans la table dédiée du monitoring.","operationId":"get_monitoring_count_monitorings__id_monito__count_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/columns":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Columns","description":"Retourne les colonnes de la table dédiée.\nPermet au frontend de générer dynamiquement les en-têtes du tableau.","operationId":"get_monitoring_columns_monitorings__id_monito__columns_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/dashboard":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard","description":"Vue consolidée — source principale du dashboard.","operationId":"get_dashboard_dashboard_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"service","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filtrer par service","title":"Service"},"description":"Filtrer par service"},{"name":"categorie","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filtrer par catégorie","title":"Categorie"},"description":"Filtrer par catégorie"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/dashboard/filtres":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard Filtres","description":"Valeurs de filtre réellement présentes dans VUE_CONSO.\n\nLes référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles\naucun monitoring actif n'est rattaché (ex. « Business Intelligence »).\nLes proposer dans les menus déroulants du dashboard mène à un écran vide :\ncet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring.\n\n`combinaisons` permet au frontend de restreindre les catégories proposées\nau service sélectionné.","operationId":"get_dashboard_filtres_dashboard_filtres_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/dashboard/summary":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard Summary","description":"KPI globaux pour les 4 cartes du dashboard.","operationId":"get_dashboard_summary_dashboard_summary_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/historique":{"get":{"tags":["Historique"],"summary":"Get Historique","description":"Snapshots journaliers (TABLE_FINAL).","operationId":"get_historique_historique_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id Monito"}},{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"},{"name":"service","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Service"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/historique/{id_monito}/evolution":{"get":{"tags":["Historique"],"summary":"Get Evolution","description":"Évolution d'un monitoring — format optimisé Recharts.","operationId":"get_evolution_historique__id_monito__evolution_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}},{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Debut"}},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Fin"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/historique/comparaison":{"get":{"tags":["Historique"],"summary":"Get Comparaison","description":"Déprécié — utiliser GET /evolution/par-monitoring à la place.\nConservé pour compatibilité ascendante.","operationId":"get_comparaison_historique_comparaison_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Debut"}},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Fin"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/global":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Global","description":"Courbe globale : total des erreurs tous monitorings confondus, par jour.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL'\nFormat optimisé Recharts → [{date, nb_erreurs}]","operationId":"get_evolution_global_evolution_global_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/par-monitoring":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Par Monitoring","description":"Évolution par monitoring — toutes les courbes individuelles.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO'\n\nRetourne un format pivot par monitoring :\n{\n \"monitorings\": [{\"id\": 1, \"nom\": \"...\", \"serie\": [{\"date\",\"nb_erreurs\"}]}]\n}\nUtile pour un graphique multi-lignes Recharts (une ligne par monitoring).","operationId":"get_evolution_par_monitoring_evolution_par_monitoring_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"},{"name":"id_monito","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer sur un seul monitoring","title":"Id Monito"},"description":"Filtrer sur un seul monitoring"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/par-service":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Par Service","description":"Évolution par service (Contrat / Fournisseur) — 1 courbe par service.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE'\n\nRetourne :\n{\n \"services\": [{\"service\": \"Contrat\", \"serie\": [{\"date\",\"nb_erreurs\"}]}]\n}","operationId":"get_evolution_par_service_evolution_par_service_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"Body_login_auth_login_post":{"properties":{"grant_type":{"anyOf":[{"type":"string","pattern":"^password$"},{"type":"null"}],"title":"Grant Type"},"username":{"type":"string","title":"Username"},"password":{"type":"string","format":"password","title":"Password"},"scope":{"type":"string","title":"Scope","default":""},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"password","title":"Client Secret"}},"type":"object","required":["username","password"],"title":"Body_login_auth_login_post"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"PasswordReset":{"properties":{"password":{"type":"string","title":"Password"}},"type":"object","required":["password"],"title":"PasswordReset"},"UserCreate":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"},"role":{"type":"string","title":"Role"}},"type":"object","required":["username","email","password","role"],"title":"UserCreate"},"UserUpdate":{"properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role"},"actif":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Actif"}},"type":"object","title":"UserUpdate"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{},"tokenUrl":"auth/login"}}}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Data Sentinel API","description":"API de monitoring de la qualité des données — XEFI","version":"1.0.0"},"paths":{"/health":{"get":{"tags":["Système"],"summary":"Health Check","description":"Ping API + test connexion SQL Server.","operationId":"health_check_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/auth/login":{"post":{"tags":["Auth"],"summary":"Login","description":"Authentifie un utilisateur et retourne un JWT.","operationId":"login_auth_login_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_login_auth_login_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/me":{"get":{"tags":["Auth"],"summary":"Me","description":"Profil de l'utilisateur connecté.","operationId":"me_auth_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/categories":{"get":{"tags":["Référentiels"],"summary":"Get Categories","description":"Toutes les catégories de monitoring.","operationId":"get_categories_categories_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["Référentiels"],"summary":"Create Categorie","operationId":"create_categorie_categories_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategorieBody"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/categories/{id_categorie}":{"put":{"tags":["Référentiels"],"summary":"Update Categorie","operationId":"update_categorie_categories__id_categorie__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_categorie","in":"path","required":true,"schema":{"type":"integer","title":"Id Categorie"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategorieBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Référentiels"],"summary":"Delete Categorie","description":"Supprime une catégorie, sauf si des monitorings s'y rattachent encore :\nla clé étrangère FK_NOM_CATEGORIE l'interdirait, autant renvoyer un\nmessage explicite plutôt que de laisser remonter l'erreur SQL brute.","operationId":"delete_categorie_categories__id_categorie__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_categorie","in":"path","required":true,"schema":{"type":"integer","title":"Id Categorie"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/services":{"get":{"tags":["Référentiels"],"summary":"Get Services","description":"Tous les services.","operationId":"get_services_services_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["Référentiels"],"summary":"Create Service","operationId":"create_service_services_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceBody"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/services/{id_service}":{"put":{"tags":["Référentiels"],"summary":"Update Service","operationId":"update_service_services__id_service__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"path","required":true,"schema":{"type":"integer","title":"Id Service"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Référentiels"],"summary":"Delete Service","description":"Refuse la suppression tant que des monitorings ou contacts y sont rattachés.","operationId":"delete_service_services__id_service__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"path","required":true,"schema":{"type":"integer","title":"Id Service"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/contacts":{"get":{"tags":["Référentiels"],"summary":"Get Contacts","description":"Contacts, filtrables par service.","operationId":"get_contacts_contacts_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par service","title":"Id Service"},"description":"Filtrer par service"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Référentiels"],"summary":"Create Contact","operationId":"create_contact_contacts_post","security":[{"OAuth2PasswordBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactBody"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/contacts/{id_contact}":{"put":{"tags":["Référentiels"],"summary":"Update Contact","operationId":"update_contact_contacts__id_contact__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_contact","in":"path","required":true,"schema":{"type":"integer","title":"Id Contact"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Référentiels"],"summary":"Delete Contact","description":"Un contact n'est référencé par aucune autre table : suppression directe.","operationId":"delete_contact_contacts__id_contact__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_contact","in":"path","required":true,"schema":{"type":"integer","title":"Id Contact"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings":{"get":{"tags":["Monitorings"],"summary":"Get Monitorings","description":"Liste des monitorings actifs avec table_source et bdd_source.","operationId":"get_monitorings_monitorings_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par service","title":"Id Service"},"description":"Filtrer par service"},{"name":"id_categorie","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par catégorie","title":"Id Categorie"},"description":"Filtrer par catégorie"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Monitorings"],"summary":"Create Monitoring","description":"Déclare un monitoring dans la nomenclature.\n\nAttention : cela n'ajoute que l'entrée de référentiel. Pour qu'il remonte\ndes données, il faut aussi créer la table MONITO_[NOM] correspondante et\nl'ajouter au mapping MONITO_TABLES — voir l'en-tête de data_sentinel_init.sql.","operationId":"create_monitoring_monitorings_post","security":[{"OAuth2PasswordBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MonitoringBody"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}":{"put":{"tags":["Monitorings"],"summary":"Update Monitoring","operationId":"update_monitoring_monitorings__id_monito__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MonitoringUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Monitorings"],"summary":"Desactiver Monitoring","description":"Désactivation (actif = 0), pas de suppression physique : TABLE_FINAL\nréférence id_monito et l'historique doit rester consultable.","operationId":"desactiver_monitoring_monitorings__id_monito__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["Monitorings"],"summary":"Get Monitoring By Id","description":"Détail d'un monitoring.","operationId":"get_monitoring_by_id_monitorings__id_monito__get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/details":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Details","description":"Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée.\nToutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend.","operationId":"get_monitoring_details_monitorings__id_monito__details_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}},{"name":"search","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Recherche sur colonnes texte (ct_intitule, agence, STE...)","title":"Search"},"description":"Recherche sur colonnes texte (ct_intitule, agence, STE...)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":5000,"minimum":1,"description":"Lignes max — mettre 5000 pour export CSV complet","default":500,"title":"Limit"},"description":"Lignes max — mettre 5000 pour export CSV complet"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Offset pagination","default":0,"title":"Offset"},"description":"Offset pagination"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/count":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Count","description":"Nombre d'erreurs dans la table dédiée du monitoring.","operationId":"get_monitoring_count_monitorings__id_monito__count_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/columns":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Columns","description":"Retourne les colonnes de la table dédiée.\nPermet au frontend de générer dynamiquement les en-têtes du tableau.","operationId":"get_monitoring_columns_monitorings__id_monito__columns_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/dashboard":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard","description":"Vue consolidée — source principale du dashboard.","operationId":"get_dashboard_dashboard_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"service","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filtrer par service","title":"Service"},"description":"Filtrer par service"},{"name":"categorie","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filtrer par catégorie","title":"Categorie"},"description":"Filtrer par catégorie"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/dashboard/filtres":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard Filtres","description":"Valeurs de filtre réellement présentes dans VUE_CONSO.\n\nLes référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles\naucun monitoring actif n'est rattaché (ex. « Business Intelligence »).\nLes proposer dans les menus déroulants du dashboard mène à un écran vide :\ncet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring.\n\n`combinaisons` permet au frontend de restreindre les catégories proposées\nau service sélectionné.","operationId":"get_dashboard_filtres_dashboard_filtres_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/dashboard/summary":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard Summary","description":"KPI globaux pour les 4 cartes du dashboard.","operationId":"get_dashboard_summary_dashboard_summary_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/historique":{"get":{"tags":["Historique"],"summary":"Get Historique","description":"Snapshots journaliers (TABLE_FINAL).","operationId":"get_historique_historique_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id Monito"}},{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"},{"name":"service","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Service"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/historique/comparaison":{"get":{"tags":["Historique"],"summary":"Get Comparaison","description":"Déprécié — utiliser GET /evolution/par-monitoring à la place.\nConservé pour compatibilité ascendante.","operationId":"get_comparaison_historique_comparaison_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Debut"}},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Fin"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/historique/{id_monito}/evolution":{"get":{"tags":["Historique"],"summary":"Get Evolution","description":"Évolution d'un monitoring — format optimisé Recharts.","operationId":"get_evolution_historique__id_monito__evolution_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}},{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Debut"}},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Fin"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/global":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Global","description":"Courbe globale : total des erreurs tous monitorings confondus, par jour.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL'\nFormat optimisé Recharts → [{date, nb_erreurs}]","operationId":"get_evolution_global_evolution_global_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/par-monitoring":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Par Monitoring","description":"Évolution par monitoring — toutes les courbes individuelles.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO'\n\nRetourne un format pivot par monitoring :\n{\n \"monitorings\": [{\"id\": 1, \"nom\": \"...\", \"serie\": [{\"date\",\"nb_erreurs\"}]}]\n}\nUtile pour un graphique multi-lignes Recharts (une ligne par monitoring).","operationId":"get_evolution_par_monitoring_evolution_par_monitoring_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"},{"name":"id_monito","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer sur un seul monitoring","title":"Id Monito"},"description":"Filtrer sur un seul monitoring"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/par-service":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Par Service","description":"Évolution par service (Contrat / Fournisseur) — 1 courbe par service.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE'\n\nRetourne :\n{\n \"services\": [{\"service\": \"Contrat\", \"serie\": [{\"date\",\"nb_erreurs\"}]}]\n}","operationId":"get_evolution_par_service_evolution_par_service_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/users":{"get":{"tags":["Admin"],"summary":"List Users","operationId":"list_users_admin_users_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["Admin"],"summary":"Create User","operationId":"create_user_admin_users_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/admin/users/{id_user}":{"put":{"tags":["Admin"],"summary":"Update User","operationId":"update_user_admin_users__id_user__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Admin"],"summary":"Delete User","description":"Suppression douce (actif = 0).","operationId":"delete_user_admin_users__id_user__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/users/{id_user}/reset-password":{"post":{"tags":["Admin"],"summary":"Reset Password","operationId":"reset_password_admin_users__id_user__reset_password_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordReset"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/journal":{"get":{"tags":["Admin"],"summary":"Get Journal","operationId":"get_journal_admin_journal_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":200,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/me/data-export":{"get":{"tags":["RGPD"],"summary":"Export My Data","description":"Droit à la portabilité (art. 20) : export des données de l'utilisateur.","operationId":"export_my_data_me_data_export_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/me":{"delete":{"tags":["RGPD"],"summary":"Delete My Account","description":"Droit à l'oubli (art. 17) : désactivation + anonymisation.","operationId":"delete_my_account_me_delete","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}}},"components":{"schemas":{"Body_login_auth_login_post":{"properties":{"grant_type":{"anyOf":[{"type":"string","pattern":"^password$"},{"type":"null"}],"title":"Grant Type"},"username":{"type":"string","title":"Username"},"password":{"type":"string","format":"password","title":"Password"},"scope":{"type":"string","title":"Scope","default":""},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"password","title":"Client Secret"}},"type":"object","required":["username","password"],"title":"Body_login_auth_login_post"},"CategorieBody":{"properties":{"intitule_categorie":{"type":"string","maxLength":100,"minLength":1,"title":"Intitule Categorie"}},"type":"object","required":["intitule_categorie"],"title":"CategorieBody"},"ContactBody":{"properties":{"id_service":{"type":"integer","title":"Id Service"},"intitule_contact":{"type":"string","maxLength":100,"minLength":1,"title":"Intitule Contact"},"nom":{"type":"string","maxLength":100,"minLength":1,"title":"Nom"},"prenom":{"type":"string","maxLength":100,"minLength":1,"title":"Prenom"},"mail":{"type":"string","maxLength":200,"minLength":1,"title":"Mail"}},"type":"object","required":["id_service","intitule_contact","nom","prenom","mail"],"title":"ContactBody"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"MonitoringBody":{"properties":{"monito_intitule":{"type":"string","maxLength":200,"minLength":1,"title":"Monito Intitule"},"id_service":{"type":"integer","title":"Id Service"},"id_categorie":{"type":"integer","title":"Id Categorie"},"table_source":{"type":"string","maxLength":100,"minLength":1,"title":"Table Source"},"bdd_source":{"type":"string","maxLength":50,"minLength":1,"title":"Bdd Source"}},"type":"object","required":["monito_intitule","id_service","id_categorie","table_source","bdd_source"],"title":"MonitoringBody"},"MonitoringUpdate":{"properties":{"monito_intitule":{"anyOf":[{"type":"string","maxLength":200,"minLength":1},{"type":"null"}],"title":"Monito Intitule"},"id_service":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id Service"},"id_categorie":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id Categorie"},"table_source":{"anyOf":[{"type":"string","maxLength":100,"minLength":1},{"type":"null"}],"title":"Table Source"},"bdd_source":{"anyOf":[{"type":"string","maxLength":50,"minLength":1},{"type":"null"}],"title":"Bdd Source"},"actif":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Actif"}},"type":"object","title":"MonitoringUpdate"},"PasswordReset":{"properties":{"password":{"type":"string","title":"Password"}},"type":"object","required":["password"],"title":"PasswordReset"},"ServiceBody":{"properties":{"nom_service":{"type":"string","maxLength":100,"minLength":1,"title":"Nom Service"}},"type":"object","required":["nom_service"],"title":"ServiceBody"},"UserCreate":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"},"role":{"$ref":"#/components/schemas/UserRole"}},"type":"object","required":["username","email","password","role"],"title":"UserCreate"},"UserRole":{"type":"string","enum":["Admin","Superviseur","Consultant"],"title":"UserRole","description":"Rôles applicatifs — miroir de la contrainte CK_USER_ROLE en base."},"UserUpdate":{"properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"role":{"anyOf":[{"$ref":"#/components/schemas/UserRole"},{"type":"null"}]},"actif":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Actif"}},"type":"object","title":"UserUpdate"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{},"tokenUrl":"auth/login"}}}}}} \ No newline at end of file diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 96c2958..091b79c 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -66,6 +66,70 @@ def test_monitoring_unknown_returns_404(client, cur, auth_headers): assert r.status_code == 404 +def test_create_user_role_invalide_rejete(client, cur, auth_headers): + # Le rôle est contraint par l'enum UserRole : Pydantic rejette en 422 + # avant d'atteindre la base, sans validation manuelle dans la route. + r = client.post( + "/admin/users", + headers=auth_headers, + json={"username": "x", "email": "x@x.fr", "password": "p", "role": "Root"}, + ) + assert r.status_code == 422 + + +def test_create_user_role_valide_accepte(client, cur, auth_headers): + r = client.post( + "/admin/users", + headers=auth_headers, + json={"username": "x", "email": "x@x.fr", "password": "p", "role": "Superviseur"}, + ) + assert r.status_code == 201 + + +def test_creation_service_reservee_admin(client, cur): + # Un Consultant ne doit pas pouvoir écrire dans le référentiel. + token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2}) + r = client.post( + "/services", + headers={"Authorization": f"Bearer {token}"}, + json={"nom_service": "Nouveau"}, + ) + assert r.status_code == 403 + + +def test_creation_service_par_admin(client, cur, auth_headers): + cur._one = (42,) + r = client.post("/services", headers=auth_headers, json={"nom_service": "Comptabilité"}) + assert r.status_code == 201 + assert r.json() == {"id_service": 42, "nom_service": "Comptabilité"} + + +def test_suppression_service_refusee_si_rattachements(client, cur, auth_headers): + # 2 monitorings et 1 contact rattachés : la suppression doit être bloquée + # explicitement plutôt que de laisser remonter une violation de clé étrangère. + cur._one = (2, 1) + r = client.delete("/services/1", headers=auth_headers) + assert r.status_code == 409 + assert "2 monitoring(s)" in r.json()["detail"] + + +def test_suppression_service_sans_rattachement(client, cur, auth_headers): + cur._one = (0, 0) + r = client.delete("/services/1", headers=auth_headers) + assert r.status_code == 200 + assert r.json()["status"] == "deleted" + + +def test_desactivation_monitoring_ne_supprime_pas(client, cur, auth_headers): + # TABLE_FINAL référence id_monito : on désactive, on ne supprime jamais. + r = client.delete("/monitorings/1", headers=auth_headers) + assert r.status_code == 200 + assert r.json()["status"] == "deactivated" + requetes = " ".join(q for q, _ in cur.executed) + assert "UPDATE NOMENCLATURE_MONITO SET actif = 0" in requetes + assert "DELETE FROM NOMENCLATURE_MONITO" not in requetes + + def test_admin_forbidden_for_consultant(client): token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2}) r = client.get("/admin/users", headers={"Authorization": f"Bearer {token}"})