# ============================================================ # main.py — API FastAPI Data Sentinel # COYAUD Anthony | 2026 # V2 : une table dédiée par monitoring # # Lancement : uvicorn main:app --reload --port 8000 # Swagger : http://localhost:8000/docs # ============================================================ import os from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from typing import Optional from datetime import date from config import Config, get_cursor # Origines autorisées : depuis CORS_ORIGINS (séparées par des virgules) en prod, # localhost par défaut en dev. CORS_ORIGINS = [ o.strip() for o in os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:3000").split(",") if o.strip() ] # ============================================================ # Initialisation # ============================================================ app = FastAPI( title = Config.API_TITLE, version = Config.API_VERSION, description = Config.API_DESCRIPTION, ) app.add_middleware( CORSMiddleware, allow_origins = CORS_ORIGINS, allow_methods = ["GET"], allow_headers = ["*"], ) # ============================================================ # 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 # ============================================================ @app.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()) @app.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()) @app.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 # ============================================================ @app.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()) @app.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 # ============================================================ @app.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] @app.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], } @app.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 # ============================================================ @app.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()) @app.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 # ============================================================ @app.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()) @app.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} @app.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) # ============================================================ @app.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, } @app.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()), } @app.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É # ============================================================ @app.get("/health", tags=["Système"]) def health_check(): """Ping API + test connexion SQL Server.""" try: with get_cursor() as cursor: cursor.execute("SELECT 1") cursor.fetchone() db_status = "ok" except Exception as e: db_status = f"error: {str(e)}" return { "api" : "ok", "database" : db_status, "version" : Config.API_VERSION, "nb_monitorings" : len(MONITO_TABLES), }