From 05d60d5e55f1802d6ec19a6b2315c7f4baa9b10f Mon Sep 17 00:00:00 2001 From: anthonycoyaud-prog Date: Wed, 20 May 2026 10:35:05 +0200 Subject: [PATCH] init projet --- config.py | 60 ++++++ main.py | 585 +++++++++++++++++++++++++++++++++++++++++++++++++++++ testAPI.py | 47 +++++ 3 files changed, 692 insertions(+) create mode 100644 config.py create mode 100644 main.py create mode 100644 testAPI.py diff --git a/config.py b/config.py new file mode 100644 index 0000000..481990d --- /dev/null +++ b/config.py @@ -0,0 +1,60 @@ +# ============================================================ +# config.py — Configuration & connexion SQL Server +# Data Sentinel | COYAUD Anthony | 2026 +# ============================================================ + +import pyodbc +from contextlib import contextmanager + + +class Config: + # Chaîne de connexion SQL Server + DB_CONNECTION_STRING = ( + "Driver={ODBC Driver 17 for SQL Server};" + "Server=LaptopCA\\SQLEXPRESS;" + "Database=DataSentinel;" + "Trusted_Connection=yes;" + ) + + # Paramètres API + API_TITLE = "Data Sentinel API" + API_VERSION = "1.0.0" + API_DESCRIPTION = "API de monitoring de la qualité des données — XEFI" + + # Sécurité JWT + SECRET_KEY = "data-sentinel-secret-change-in-prod" + ALGORITHM = "HS256" + TOKEN_EXPIRE_MINUTES = 60 + + +# ---------------------------------------------------------------- +# Connexion — retourne un curseur pyodbc via context manager +# ---------------------------------------------------------------- + +def get_connection() -> pyodbc.Connection: + """Ouvre une connexion SQL Server et la retourne.""" + return pyodbc.connect(Config.DB_CONNECTION_STRING) + + +@contextmanager +def get_cursor(): + """ + Context manager : ouvre connexion + curseur, commit ou rollback, + ferme proprement. + + Usage : + with get_cursor() as cursor: + cursor.execute("SELECT ...") + rows = cursor.fetchall() + """ + conn = get_connection() + cursor = conn.cursor() + try: + yield cursor + conn.commit() + except Exception: + conn.rollback() + raise + finally: + cursor.close() + conn.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..ffefada --- /dev/null +++ b/main.py @@ -0,0 +1,585 @@ +# ============================================================ +# 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 +# ============================================================ + +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 + +# ============================================================ +# Initialisation +# ============================================================ + +app = FastAPI( + title = Config.API_TITLE, + version = Config.API_VERSION, + description = Config.API_DESCRIPTION, +) + +app.add_middleware( + CORSMiddleware, + allow_origins = ["http://localhost:5173", "http://localhost:3000"], + 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), + } diff --git a/testAPI.py b/testAPI.py new file mode 100644 index 0000000..20eaa4f --- /dev/null +++ b/testAPI.py @@ -0,0 +1,47 @@ +import requests +import json + +# Configuration +BASE_URL = "http://127.0.0.1:8000" + +def test_get_dashboard(): + print(f"--- Test de l'endpoint: {BASE_URL}/dashboard ---") + + try: + # 1. Envoi de la requête + response = requests.get(f"{BASE_URL}/dashboard") + + # 2. Vérification du code statut + if response.status_code == 200: + data = response.json() + + if not data: + print("L'API a répondu avec succès, mais le tableau est vide (VUE_CONSO ne contient rien).") + return + + # 3. Affichage formaté du tableau + print(f"{'ID':<5} | {'NOM DU MONITORING':<40} | {'ERREURS':<8} | {'SERVICE':<15}") + print("-" * 75) + + for item in data: + print(f"{item['id_monito']:<5} | {item['nom_monito'][:38]:<40} | {item['nb_erreurs']:<8} | {item['service']:<15}") + + print(f"\nTotal de lignes récupérées : {len(data)}") + + else: + print(f"Erreur lors de la requête : {response.status_code}") + print(response.text) + + except requests.exceptions.ConnectionError: + print("Erreur : Impossible de se connecter à l'API. Est-ce qu'uvicorn est lancé ?") + +if __name__ == "__main__": + # Petit check de santé avant de tester les données + print("Vérification de l'état de l'API...") + health = requests.get(f"{BASE_URL}/health").json() + print(f"Statut API : {health['api']} | Statut DB : {health['database']}\n") + + if health['database'] == "ok": + test_get_dashboard() + else: + print("Abandon du test : La base de données n'est pas accessible.") \ No newline at end of file