Build & Deploy / build (push) Successful in 17s
- auth.py: JWT (python-jose) + bcrypt (passlib), get_current_user, require_admin - endpoints protégés via APIRouter(dependencies=[get_current_user]); /health public - /auth/login (rate-limit 5/min) + /auth/me - /admin/users CRUD + reset-password + /admin/journal (require_admin) - RGPD: /me/data-export (portabilité) + DELETE /me (oubli/anonymisation) - en-têtes de sécurité, CORS tous verbes + credentials - requirements: python-jose, passlib[bcrypt], bcrypt 4.0.1, python-multipart, slowapi
803 lines
28 KiB
Python
803 lines
28 KiB
Python
# ============================================================
|
|
# 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, APIRouter, Depends, 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.errors import RateLimitExceeded
|
|
|
|
from config import Config, get_cursor
|
|
from auth import (
|
|
verify_password, hash_password, create_access_token,
|
|
get_current_user, require_admin,
|
|
)
|
|
|
|
# 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,
|
|
)
|
|
|
|
# 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)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins = CORS_ORIGINS,
|
|
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
allow_headers = ["*"],
|
|
allow_credentials = True,
|
|
)
|
|
|
|
|
|
# En-têtes de sécurité sur toutes les réponses.
|
|
@app.middleware("http")
|
|
async def security_headers(request: Request, call_next):
|
|
response = await call_next(request)
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
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/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É
|
|
# ============================================================
|
|
|
|
@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),
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# AUTHENTIFICATION
|
|
# ============================================================
|
|
|
|
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)
|