# ============================================================ # 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)