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