init projet

This commit is contained in:
2026-05-20 10:35:05 +02:00
parent 752af0e58d
commit 05d60d5e55
3 changed files with 692 additions and 0 deletions
+60
View File
@@ -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()
+585
View File
@@ -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),
}
+47
View File
@@ -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.")