Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb84e20e9a | ||
|
|
e7b3944436 | ||
|
|
934e669774 | ||
|
|
7b3c91509f |
@@ -21,15 +21,17 @@ python -m uvicorn main:app --reload --port 8000
|
||||
- Swagger : `http://127.0.0.1:8000/docs` · Santé : `http://127.0.0.1:8000/health`
|
||||
|
||||
Sans variables d'environnement, l'API se connecte en authentification Windows
|
||||
(`LaptopCA\SQLEXPRESS`) — comportement de dev d'origine, inchangé.
|
||||
sur `localhost`. Pour une instance nommée, définir `DB_SERVER` (ex.
|
||||
`DB_SERVER=MonPoste\SQLEXPRESS`, sans `DB_PORT`).
|
||||
|
||||
## Configuration (variables d'environnement)
|
||||
|
||||
| Variable | Rôle | Défaut |
|
||||
|----------|------|--------|
|
||||
| `DB_SERVER` | hôte SQL Server (active l'auth SQL si défini) | — (sinon Windows local) |
|
||||
| `DB_PORT` / `DB_NAME` | port / base | `1433` / `DataSentinel` |
|
||||
| `DB_USER` / `DB_PASSWORD` | compte applicatif | — |
|
||||
| `DB_SERVER` | hôte SQL Server (ou `hôte\instance`) | `localhost` |
|
||||
| `DB_PORT` / `DB_NAME` | port / base — laisser `DB_PORT` vide pour une instance nommée | — / `DataSentinel` |
|
||||
| `DB_USER` / `DB_PASSWORD` | compte applicatif ; sa présence active l'authentification SQL | — (sinon auth Windows) |
|
||||
| `DB_TRUSTED_CONNECTION` | force l'authentification Windows même si `DB_USER` est défini | — |
|
||||
| `DB_DRIVER` | pilote ODBC | `ODBC Driver 18 for SQL Server` |
|
||||
| `CORS_ORIGINS` | origines autorisées (séparées par `,`) | `localhost:5173,localhost:3000` |
|
||||
| `JWT_SECRET` | clé de signature JWT | placeholder (à définir en prod) |
|
||||
@@ -62,10 +64,32 @@ Les mots de passe ci-dessus sont des identifiants de démonstration : ils sont
|
||||
stockés hachés (bcrypt, coût 12) et doivent être régénérés avant toute mise en
|
||||
production réelle (`POST /admin/users/{id}/reset-password`).
|
||||
|
||||
## Organisation du code
|
||||
|
||||
`main.py` ne fait que l'assemblage (configuration, middlewares, montage des
|
||||
routeurs). Chaque domaine fonctionnel vit dans `routers/` :
|
||||
|
||||
| Module | Responsabilité |
|
||||
|--------|----------------|
|
||||
| `routers/authentification.py` | `/auth/login` (rate-limité), `/auth/me` |
|
||||
| `routers/referentiels.py` | services, catégories, contacts — lecture + CRUD Admin |
|
||||
| `routers/monitorings.py` | nomenclature + données détaillées par table dédiée |
|
||||
| `routers/dashboard.py` | `VUE_CONSO` : vue consolidée, filtres, KPI |
|
||||
| `routers/historique.py` | snapshots journaliers (`TABLE_FINAL`) |
|
||||
| `routers/evolution.py` | `VUE_TABLE_FINAL_CONSO` : courbes global / monitoring / service |
|
||||
| `routers/admin.py` | comptes utilisateurs et journal d'audit |
|
||||
| `routers/rgpd.py` | portabilité et droit à l'oubli |
|
||||
| `domain.py` | enums (`UserRole`, `AuditAction`) et mapping `MONITO_TABLES` |
|
||||
| `helpers.py` | conversion des lignes pyodbc, écriture du journal d'audit |
|
||||
|
||||
## Endpoints (résumé)
|
||||
|
||||
- **Données** (protégés) : `/categories`, `/services`, `/contacts`, `/monitorings[...]`,
|
||||
`/dashboard[...]`, `/historique[...]`, `/evolution/*`.
|
||||
- **Référentiels** (`Admin` en écriture) : `POST/PUT/DELETE` sur `/services`,
|
||||
`/categories`, `/contacts` et `/monitorings`. La suppression est refusée (409)
|
||||
tant que des enregistrements y sont rattachés ; un monitoring est **désactivé**
|
||||
(`actif = 0`) et jamais supprimé, car `TABLE_FINAL` référence son identifiant.
|
||||
- **Admin** (`Admin`) : `GET/POST /admin/users`, `PUT/DELETE /admin/users/{id}`,
|
||||
`POST /admin/users/{id}/reset-password`, `GET /admin/journal`.
|
||||
- **RGPD** : `GET /me/data-export` (portabilité), `DELETE /me` (droit à l'oubli + anonymisation).
|
||||
@@ -86,7 +110,9 @@ Spécification complète : `GET /openapi.json` (export dans `docs/openapi.json`)
|
||||
```bash
|
||||
pip install pytest httpx && pytest -q
|
||||
```
|
||||
Curseur SQL simulé (aucune vraie BDD) : santé, auth (succès/échec), protection 401, RBAC 403, 404.
|
||||
Curseur SQL simulé (aucune vraie BDD) : santé, auth (succès/échec), protection 401,
|
||||
RBAC 403, 404, validation des rôles par enum (422), refus de suppression d'un
|
||||
référentiel rattaché (409), désactivation logique d'un monitoring.
|
||||
|
||||
## Docker & CI/CD
|
||||
|
||||
|
||||
@@ -10,27 +10,38 @@ from contextlib import contextmanager
|
||||
|
||||
def _build_connection_string() -> str:
|
||||
"""
|
||||
En déploiement (variables d'environnement présentes), on se connecte en
|
||||
authentification SQL. Sinon, on garde la connexion Windows locale par défaut
|
||||
(poste de dev).
|
||||
Construit la chaîne de connexion ODBC à partir des variables
|
||||
d'environnement.
|
||||
|
||||
Deux modes d'authentification :
|
||||
• authentification SQL (déploiement) — dès que DB_USER est défini ;
|
||||
• authentification Windows (poste de dev) — sinon, ou en forçant
|
||||
DB_TRUSTED_CONNECTION=yes.
|
||||
|
||||
Le serveur par défaut reste modifiable par DB_SERVER : coder en dur le
|
||||
nom d'un poste rendait l'API inutilisable sur toute autre machine.
|
||||
"""
|
||||
server = os.getenv("DB_SERVER")
|
||||
if server:
|
||||
driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server")
|
||||
port = os.getenv("DB_PORT", "1433")
|
||||
return (
|
||||
f"Driver={{{driver}}};"
|
||||
f"Server={server},{port};"
|
||||
f"Database={os.getenv('DB_NAME', 'DataSentinel')};"
|
||||
f"UID={os.getenv('DB_USER')};"
|
||||
f"PWD={os.getenv('DB_PASSWORD')};"
|
||||
"Encrypt=yes;TrustServerCertificate=yes;"
|
||||
)
|
||||
server = os.getenv("DB_SERVER", "localhost")
|
||||
port = os.getenv("DB_PORT", "")
|
||||
base = os.getenv("DB_NAME", "DataSentinel")
|
||||
driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server")
|
||||
user = os.getenv("DB_USER")
|
||||
|
||||
# Une instance nommée (MonPoste\SQLEXPRESS) se joint sans port explicite.
|
||||
adresse = f"{server},{port}" if port else server
|
||||
|
||||
trusted = os.getenv("DB_TRUSTED_CONNECTION", "").lower() in ("1", "yes", "true")
|
||||
if user and not trusted:
|
||||
identification = f"UID={user};PWD={os.getenv('DB_PASSWORD', '')};"
|
||||
else:
|
||||
identification = "Trusted_Connection=yes;"
|
||||
|
||||
return (
|
||||
"Driver={ODBC Driver 17 for SQL Server};"
|
||||
"Server=LaptopCA\\SQLEXPRESS;"
|
||||
"Database=DataSentinel;"
|
||||
"Trusted_Connection=yes;"
|
||||
f"Driver={{{driver}}};"
|
||||
f"Server={adresse};"
|
||||
f"Database={base};"
|
||||
f"{identification}"
|
||||
"Encrypt=yes;TrustServerCertificate=yes;"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,51 @@
|
||||
# ============================================================
|
||||
# domain.py — Constantes et énumérations du domaine Data Sentinel
|
||||
# ============================================================
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""Rôles applicatifs — miroir de la contrainte CK_USER_ROLE en base."""
|
||||
ADMIN = "Admin"
|
||||
SUPERVISEUR = "Superviseur"
|
||||
CONSULTANT = "Consultant"
|
||||
|
||||
|
||||
class AuditAction(str, Enum):
|
||||
"""Actions tracées dans JOURNAL_AUDIT."""
|
||||
LOGIN = "LOGIN"
|
||||
CREATE_USER = "CREATE_USER"
|
||||
UPDATE_USER = "UPDATE_USER"
|
||||
DELETE_USER = "DELETE_USER"
|
||||
RESET_PASSWORD = "RESET_PASSWORD"
|
||||
CREATE_REFERENTIEL = "CREATE_REFERENTIEL"
|
||||
UPDATE_REFERENTIEL = "UPDATE_REFERENTIEL"
|
||||
DELETE_REFERENTIEL = "DELETE_REFERENTIEL"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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"],
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# ============================================================
|
||||
# helpers.py — Conversion des résultats pyodbc et journal d'audit
|
||||
# ============================================================
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from domain import MONITO_TABLES, AuditAction
|
||||
|
||||
|
||||
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=status.HTTP_404_NOT_FOUND,
|
||||
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
|
||||
|
||||
|
||||
def journaliser(cursor, acteur: dict, action: AuditAction, detail: str) -> None:
|
||||
"""Trace une action d'administration dans JOURNAL_AUDIT."""
|
||||
cursor.execute(
|
||||
"INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, ?, ?)",
|
||||
acteur["id_user"], acteur["username"], action.value, detail
|
||||
)
|
||||
@@ -3,27 +3,34 @@
|
||||
# COYAUD Anthony | 2026
|
||||
# V2 : une table dédiée par monitoring
|
||||
#
|
||||
# Ce module ne contient que l'assemblage de l'application :
|
||||
# configuration, middlewares et montage des routeurs. Chaque
|
||||
# domaine fonctionnel vit dans routers/.
|
||||
#
|
||||
# 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 import FastAPI, 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 import _rate_limit_exceeded_handler
|
||||
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,
|
||||
from domain import MONITO_TABLES
|
||||
from rate_limit import limiter
|
||||
from routers import (
|
||||
admin,
|
||||
authentification,
|
||||
dashboard,
|
||||
evolution,
|
||||
historique,
|
||||
monitorings,
|
||||
referentiels,
|
||||
rgpd,
|
||||
)
|
||||
|
||||
# Origines autorisées : depuis CORS_ORIGINS (séparées par des virgules) en prod,
|
||||
@@ -45,7 +52,6 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -69,570 +75,8 @@ async def security_headers(request: Request, call_next):
|
||||
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/filtres", tags=["Dashboard"])
|
||||
def get_dashboard_filtres():
|
||||
"""
|
||||
Valeurs de filtre réellement présentes dans VUE_CONSO.
|
||||
|
||||
Les référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles
|
||||
aucun monitoring actif n'est rattaché (ex. « Business Intelligence »).
|
||||
Les proposer dans les menus déroulants du dashboard mène à un écran vide :
|
||||
cet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring.
|
||||
|
||||
`combinaisons` permet au frontend de restreindre les catégories proposées
|
||||
au service sélectionné.
|
||||
"""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT DISTINCT service, categorie FROM VUE_CONSO "
|
||||
"ORDER BY service, categorie"
|
||||
)
|
||||
combinaisons = [{"service": r[0], "categorie": r[1]} for r in cursor.fetchall()]
|
||||
|
||||
return {
|
||||
"services" : sorted({c["service"] for c in combinaisons}),
|
||||
"categories" : sorted({c["categorie"] for c in combinaisons}),
|
||||
"combinaisons" : combinaisons,
|
||||
}
|
||||
|
||||
|
||||
@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É
|
||||
# SANTÉ — route publique
|
||||
# ============================================================
|
||||
|
||||
@app.get("/health", tags=["Système"])
|
||||
@@ -655,175 +99,17 @@ def health_check():
|
||||
|
||||
|
||||
# ============================================================
|
||||
# AUTHENTIFICATION
|
||||
# Montage des routeurs
|
||||
#
|
||||
# Les routeurs de données déclarent eux-mêmes leur dépendance
|
||||
# d'authentification. /health et /auth/login restent publics.
|
||||
# ============================================================
|
||||
|
||||
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)
|
||||
app.include_router(authentification.router)
|
||||
app.include_router(referentiels.router)
|
||||
app.include_router(monitorings.router, prefix="/monitorings")
|
||||
app.include_router(dashboard.router, prefix="/dashboard")
|
||||
app.include_router(historique.router, prefix="/historique")
|
||||
app.include_router(evolution.router, prefix="/evolution")
|
||||
app.include_router(admin.router)
|
||||
app.include_router(rgpd.router)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# ============================================================
|
||||
# rate_limit.py — Limiteur de débit partagé (anti brute-force)
|
||||
#
|
||||
# Isolé dans son propre module pour que les routeurs puissent
|
||||
# décorer leurs routes sans importer main.py (import circulaire).
|
||||
# ============================================================
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -0,0 +1,104 @@
|
||||
# ============================================================
|
||||
# routers/admin.py — Gestion des comptes et journal d'audit
|
||||
#
|
||||
# Réservé au rôle Admin (403 sinon), via require_admin.
|
||||
# ============================================================
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import hash_password, require_admin
|
||||
from config import get_cursor
|
||||
from domain import AuditAction, UserRole
|
||||
from helpers import journaliser, rows_to_list
|
||||
|
||||
router = APIRouter(tags=["Admin"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
role: UserRole
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
actif: Optional[bool] = None
|
||||
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
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())
|
||||
|
||||
|
||||
@router.post("/admin/users", status_code=status.HTTP_201_CREATED)
|
||||
def create_user(body: UserCreate, admin: dict = Depends(require_admin)):
|
||||
# Le rôle est validé par Pydantic via l'enum UserRole (422 si 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.value
|
||||
)
|
||||
journaliser(cursor, admin, AuditAction.CREATE_USER, body.username)
|
||||
return {"status": "created", "username": body.username}
|
||||
|
||||
|
||||
@router.put("/admin/users/{id_user}")
|
||||
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:
|
||||
sets.append("role = ?"); params.append(body.role.value)
|
||||
if body.actif is not None:
|
||||
sets.append("actif = ?"); params.append(1 if body.actif else 0)
|
||||
if not sets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, 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)
|
||||
journaliser(cursor, admin, AuditAction.UPDATE_USER, str(id_user))
|
||||
return {"status": "updated", "id_user": id_user}
|
||||
|
||||
|
||||
@router.delete("/admin/users/{id_user}")
|
||||
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)
|
||||
journaliser(cursor, admin, AuditAction.DELETE_USER, str(id_user))
|
||||
return {"status": "deactivated", "id_user": id_user}
|
||||
|
||||
|
||||
@router.post("/admin/users/{id_user}/reset-password")
|
||||
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)
|
||||
journaliser(cursor, admin, AuditAction.RESET_PASSWORD, str(id_user))
|
||||
return {"status": "password_reset", "id_user": id_user}
|
||||
|
||||
|
||||
@router.get("/admin/journal")
|
||||
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())
|
||||
@@ -0,0 +1,47 @@
|
||||
# ============================================================
|
||||
# routers/authentification.py — Login JWT et profil courant
|
||||
#
|
||||
# Routes publiques (aucune dépendance d'authentification) :
|
||||
# c'est ici qu'on obtient le jeton exigé par les autres routeurs.
|
||||
# ============================================================
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from auth import create_access_token, get_current_user, verify_password
|
||||
from config import get_cursor
|
||||
from domain import AuditAction
|
||||
from rate_limit import limiter
|
||||
|
||||
router = APIRouter(tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/auth/login")
|
||||
@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=status.HTTP_401_UNAUTHORIZED, 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 (?, ?, ?, ?)",
|
||||
row[0], row[1], AuditAction.LOGIN.value, 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]}}
|
||||
|
||||
|
||||
@router.get("/auth/me")
|
||||
def me(user: dict = Depends(get_current_user)):
|
||||
"""Profil de l'utilisateur connecté."""
|
||||
return user
|
||||
@@ -0,0 +1,91 @@
|
||||
# ============================================================
|
||||
# routers/dashboard.py — Vue consolidée (VUE_CONSO)
|
||||
# ============================================================
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from auth import get_current_user
|
||||
from config import get_cursor
|
||||
from helpers import row_to_dict, rows_to_list
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Dashboard"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
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("/filtres")
|
||||
def get_dashboard_filtres():
|
||||
"""
|
||||
Valeurs de filtre réellement présentes dans VUE_CONSO.
|
||||
|
||||
Les référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles
|
||||
aucun monitoring actif n'est rattaché (ex. « Business Intelligence »).
|
||||
Les proposer dans les menus déroulants du dashboard mène à un écran vide :
|
||||
cet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring.
|
||||
|
||||
`combinaisons` permet au frontend de restreindre les catégories proposées
|
||||
au service sélectionné.
|
||||
"""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT DISTINCT service, categorie FROM VUE_CONSO "
|
||||
"ORDER BY service, categorie"
|
||||
)
|
||||
combinaisons = [{"service": r[0], "categorie": r[1]} for r in cursor.fetchall()]
|
||||
|
||||
return {
|
||||
"services" : sorted({c["service"] for c in combinaisons}),
|
||||
"categories" : sorted({c["categorie"] for c in combinaisons}),
|
||||
"combinaisons" : combinaisons,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
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
|
||||
@@ -0,0 +1,158 @@
|
||||
# ============================================================
|
||||
# routers/evolution.py — É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)
|
||||
# ============================================================
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from auth import get_current_user
|
||||
from config import get_cursor
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Évolution globale"])
|
||||
|
||||
|
||||
@router.get("/global")
|
||||
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("/par-monitoring")
|
||||
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_point, 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_point, "nb_erreurs": nb})
|
||||
|
||||
return {
|
||||
"type" : "MONITO",
|
||||
"nb_series" : len(pivot),
|
||||
"monitorings" : list(pivot.values()),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/par-service")
|
||||
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_point, service, nb = row[0], row[1], row[2]
|
||||
if service not in pivot:
|
||||
pivot[service] = []
|
||||
pivot[service].append({"date": date_point, "nb_erreurs": nb})
|
||||
|
||||
return {
|
||||
"type" : "SERVICE",
|
||||
"nb_series": len(pivot),
|
||||
"services" : [{"service": s, "serie": v} for s, v in pivot.items()],
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# ============================================================
|
||||
# routers/historique.py — Snapshots journaliers (TABLE_FINAL)
|
||||
# ============================================================
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from auth import get_current_user
|
||||
from config import get_cursor
|
||||
from helpers import get_table_name, rows_to_list
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Historique"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
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("/comparaison")
|
||||
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())
|
||||
|
||||
|
||||
@router.get("/{id_monito}/evolution")
|
||||
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}
|
||||
@@ -0,0 +1,215 @@
|
||||
# ============================================================
|
||||
# routers/monitorings.py — Nomenclature et données détaillées
|
||||
#
|
||||
# Chaque monitoring dispose d'une table MONITO_[NOM] dédiée,
|
||||
# résolue via le mapping MONITO_TABLES (domain.py).
|
||||
# ============================================================
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth import get_current_user, require_admin
|
||||
from config import get_cursor
|
||||
from domain import AuditAction, SEARCH_COLS
|
||||
from helpers import get_table_name, journaliser, row_to_dict, rows_to_list, serialize_row
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Monitorings"])
|
||||
|
||||
|
||||
class MonitoringBody(BaseModel):
|
||||
monito_intitule: str = Field(min_length=1, max_length=200)
|
||||
id_service: int
|
||||
id_categorie: int
|
||||
table_source: str = Field(min_length=1, max_length=100)
|
||||
bdd_source: str = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class MonitoringUpdate(BaseModel):
|
||||
monito_intitule: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||
id_service: Optional[int] = None
|
||||
id_categorie: Optional[int] = None
|
||||
table_source: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||
bdd_source: Optional[str] = Field(default=None, min_length=1, max_length=50)
|
||||
actif: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
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.post("", status_code=status.HTTP_201_CREATED)
|
||||
def create_monitoring(body: MonitoringBody, admin: dict = Depends(require_admin)):
|
||||
"""
|
||||
Déclare un monitoring dans la nomenclature.
|
||||
|
||||
Attention : cela n'ajoute que l'entrée de référentiel. Pour qu'il remonte
|
||||
des données, il faut aussi créer la table MONITO_[NOM] correspondante et
|
||||
l'ajouter au mapping MONITO_TABLES — voir l'en-tête de data_sentinel_init.sql.
|
||||
"""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"INSERT INTO NOMENCLATURE_MONITO "
|
||||
"(monito_intitule, id_service, id_categorie, table_source, bdd_source, actif) "
|
||||
"OUTPUT INSERTED.id_monito VALUES (?, ?, ?, ?, ?, 1)",
|
||||
body.monito_intitule, body.id_service, body.id_categorie,
|
||||
body.table_source, body.bdd_source
|
||||
)
|
||||
id_monito = cursor.fetchone()[0]
|
||||
journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL,
|
||||
f"NOMENCLATURE_MONITO {id_monito} : {body.monito_intitule}")
|
||||
return {"id_monito": id_monito, **body.model_dump()}
|
||||
|
||||
|
||||
@router.put("/{id_monito}")
|
||||
def update_monitoring(id_monito: int, body: MonitoringUpdate, admin: dict = Depends(require_admin)):
|
||||
champs = {
|
||||
"monito_intitule": body.monito_intitule,
|
||||
"id_service": body.id_service,
|
||||
"id_categorie": body.id_categorie,
|
||||
"table_source": body.table_source,
|
||||
"bdd_source": body.bdd_source,
|
||||
"actif": None if body.actif is None else int(body.actif),
|
||||
}
|
||||
modifications = {col: val for col, val in champs.items() if val is not None}
|
||||
if not modifications:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Aucun champ à modifier")
|
||||
|
||||
affectations = ", ".join(f"{col} = ?" for col in modifications)
|
||||
params = [*modifications.values(), id_monito]
|
||||
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"UPDATE NOMENCLATURE_MONITO SET {affectations} WHERE id_monito = ?", *params
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Monitoring {id_monito} introuvable.")
|
||||
journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL,
|
||||
f"NOMENCLATURE_MONITO {id_monito}")
|
||||
return {"status": "updated", "id_monito": id_monito}
|
||||
|
||||
|
||||
@router.delete("/{id_monito}")
|
||||
def desactiver_monitoring(id_monito: int, admin: dict = Depends(require_admin)):
|
||||
"""
|
||||
Désactivation (actif = 0), pas de suppression physique : TABLE_FINAL
|
||||
référence id_monito et l'historique doit rester consultable.
|
||||
"""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute("UPDATE NOMENCLATURE_MONITO SET actif = 0 WHERE id_monito = ?", id_monito)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Monitoring {id_monito} introuvable.")
|
||||
journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL,
|
||||
f"NOMENCLATURE_MONITO {id_monito} désactivé")
|
||||
return {"status": "deactivated", "id_monito": id_monito}
|
||||
|
||||
|
||||
@router.get("/{id_monito}")
|
||||
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=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Monitoring {id_monito} introuvable.")
|
||||
return row_to_dict(cursor, row)
|
||||
|
||||
|
||||
@router.get("/{id_monito}/details")
|
||||
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("/{id_monito}/count")
|
||||
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("/{id_monito}/columns")
|
||||
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()),
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
# ============================================================
|
||||
# routers/referentiels.py — Services, catégories et contacts
|
||||
#
|
||||
# Lecture ouverte à tout utilisateur authentifié ; création,
|
||||
# modification et suppression réservées aux administrateurs.
|
||||
# ============================================================
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth import get_current_user, require_admin
|
||||
from config import get_cursor
|
||||
from domain import AuditAction
|
||||
from helpers import journaliser, row_to_dict, rows_to_list
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Référentiels"])
|
||||
|
||||
|
||||
class ServiceBody(BaseModel):
|
||||
nom_service: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class CategorieBody(BaseModel):
|
||||
intitule_categorie: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ContactBody(BaseModel):
|
||||
id_service: int
|
||||
intitule_contact: str = Field(min_length=1, max_length=100)
|
||||
nom: str = Field(min_length=1, max_length=100)
|
||||
prenom: str = Field(min_length=1, max_length=100)
|
||||
mail: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Catégories
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@router.get("/categories")
|
||||
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.post("/categories", status_code=status.HTTP_201_CREATED)
|
||||
def create_categorie(body: CategorieBody, admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"INSERT INTO CATEGORIE (intitule_categorie) OUTPUT INSERTED.id_categorie VALUES (?)",
|
||||
body.intitule_categorie
|
||||
)
|
||||
id_categorie = cursor.fetchone()[0]
|
||||
journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL,
|
||||
f"CATEGORIE {id_categorie} : {body.intitule_categorie}")
|
||||
return {"id_categorie": id_categorie, "intitule_categorie": body.intitule_categorie}
|
||||
|
||||
|
||||
@router.put("/categories/{id_categorie}")
|
||||
def update_categorie(id_categorie: int, body: CategorieBody, admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE CATEGORIE SET intitule_categorie = ? WHERE id_categorie = ?",
|
||||
body.intitule_categorie, id_categorie
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Catégorie {id_categorie} introuvable.")
|
||||
journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL,
|
||||
f"CATEGORIE {id_categorie} : {body.intitule_categorie}")
|
||||
return {"id_categorie": id_categorie, "intitule_categorie": body.intitule_categorie}
|
||||
|
||||
|
||||
@router.delete("/categories/{id_categorie}")
|
||||
def delete_categorie(id_categorie: int, admin: dict = Depends(require_admin)):
|
||||
"""
|
||||
Supprime une catégorie, sauf si des monitorings s'y rattachent encore :
|
||||
la clé étrangère FK_NOM_CATEGORIE l'interdirait, autant renvoyer un
|
||||
message explicite plutôt que de laisser remonter l'erreur SQL brute.
|
||||
"""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM NOMENCLATURE_MONITO WHERE id_categorie = ?", id_categorie
|
||||
)
|
||||
nb_rattaches = cursor.fetchone()[0]
|
||||
if nb_rattaches:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Suppression impossible : {nb_rattaches} monitoring(s) utilisent cette catégorie."
|
||||
)
|
||||
cursor.execute("DELETE FROM CATEGORIE WHERE id_categorie = ?", id_categorie)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Catégorie {id_categorie} introuvable.")
|
||||
journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"CATEGORIE {id_categorie}")
|
||||
return {"status": "deleted", "id_categorie": id_categorie}
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Services
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@router.get("/services")
|
||||
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.post("/services", status_code=status.HTTP_201_CREATED)
|
||||
def create_service(body: ServiceBody, admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"INSERT INTO SERVICE (nom_service) OUTPUT INSERTED.id_service VALUES (?)",
|
||||
body.nom_service
|
||||
)
|
||||
id_service = cursor.fetchone()[0]
|
||||
journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL,
|
||||
f"SERVICE {id_service} : {body.nom_service}")
|
||||
return {"id_service": id_service, "nom_service": body.nom_service}
|
||||
|
||||
|
||||
@router.put("/services/{id_service}")
|
||||
def update_service(id_service: int, body: ServiceBody, admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE SERVICE SET nom_service = ? WHERE id_service = ?",
|
||||
body.nom_service, id_service
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Service {id_service} introuvable.")
|
||||
journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL,
|
||||
f"SERVICE {id_service} : {body.nom_service}")
|
||||
return {"id_service": id_service, "nom_service": body.nom_service}
|
||||
|
||||
|
||||
@router.delete("/services/{id_service}")
|
||||
def delete_service(id_service: int, admin: dict = Depends(require_admin)):
|
||||
"""Refuse la suppression tant que des monitorings ou contacts y sont rattachés."""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT "
|
||||
"(SELECT COUNT(*) FROM NOMENCLATURE_MONITO WHERE id_service = ?), "
|
||||
"(SELECT COUNT(*) FROM CONTACT WHERE id_service = ?)",
|
||||
id_service, id_service
|
||||
)
|
||||
nb_monitorings, nb_contacts = cursor.fetchone()
|
||||
if nb_monitorings or nb_contacts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(f"Suppression impossible : {nb_monitorings} monitoring(s) "
|
||||
f"et {nb_contacts} contact(s) rattachés à ce service.")
|
||||
)
|
||||
cursor.execute("DELETE FROM SERVICE WHERE id_service = ?", id_service)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Service {id_service} introuvable.")
|
||||
journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"SERVICE {id_service}")
|
||||
return {"status": "deleted", "id_service": id_service}
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Contacts
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@router.get("/contacts")
|
||||
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())
|
||||
|
||||
|
||||
@router.post("/contacts", status_code=status.HTTP_201_CREATED)
|
||||
def create_contact(body: ContactBody, admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"INSERT INTO CONTACT (id_service, intitule_contact, nom, prenom, mail) "
|
||||
"OUTPUT INSERTED.id_contact VALUES (?, ?, ?, ?, ?)",
|
||||
body.id_service, body.intitule_contact, body.nom, body.prenom, body.mail
|
||||
)
|
||||
id_contact = cursor.fetchone()[0]
|
||||
journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL,
|
||||
f"CONTACT {id_contact} : {body.nom} {body.prenom}")
|
||||
return {"id_contact": id_contact, **body.model_dump()}
|
||||
|
||||
|
||||
@router.put("/contacts/{id_contact}")
|
||||
def update_contact(id_contact: int, body: ContactBody, admin: dict = Depends(require_admin)):
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE CONTACT SET id_service = ?, intitule_contact = ?, nom = ?, prenom = ?, mail = ? "
|
||||
"WHERE id_contact = ?",
|
||||
body.id_service, body.intitule_contact, body.nom, body.prenom, body.mail, id_contact
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Contact {id_contact} introuvable.")
|
||||
journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL,
|
||||
f"CONTACT {id_contact} : {body.nom} {body.prenom}")
|
||||
return {"id_contact": id_contact, **body.model_dump()}
|
||||
|
||||
|
||||
@router.delete("/contacts/{id_contact}")
|
||||
def delete_contact(id_contact: int, admin: dict = Depends(require_admin)):
|
||||
"""Un contact n'est référencé par aucune autre table : suppression directe."""
|
||||
with get_cursor() as cursor:
|
||||
cursor.execute("DELETE FROM CONTACT WHERE id_contact = ?", id_contact)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Contact {id_contact} introuvable.")
|
||||
journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"CONTACT {id_contact}")
|
||||
return {"status": "deleted", "id_contact": id_contact}
|
||||
@@ -0,0 +1,43 @@
|
||||
# ============================================================
|
||||
# routers/rgpd.py — Droits de la personne concernée (RGPD)
|
||||
# ============================================================
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from auth import get_current_user
|
||||
from config import get_cursor
|
||||
from helpers import row_to_dict, rows_to_list
|
||||
|
||||
router = APIRouter(tags=["RGPD"])
|
||||
|
||||
|
||||
@router.get("/me/data-export")
|
||||
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}
|
||||
|
||||
|
||||
@router.delete("/me")
|
||||
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"}
|
||||
+21
-1
@@ -9,6 +9,23 @@ from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from auth import create_access_token
|
||||
from routers import (
|
||||
admin,
|
||||
authentification,
|
||||
dashboard,
|
||||
evolution,
|
||||
historique,
|
||||
monitorings,
|
||||
referentiels,
|
||||
rgpd,
|
||||
)
|
||||
|
||||
# Modules qui ont fait `from config import get_cursor` : l'import lie le nom
|
||||
# dans leur propre espace de noms, il faut donc le remplacer dans chacun.
|
||||
MODULES_AVEC_CURSEUR = (
|
||||
main, admin, authentification, dashboard, evolution,
|
||||
historique, monitorings, referentiels, rgpd,
|
||||
)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
@@ -17,6 +34,7 @@ class FakeCursor:
|
||||
self.description = []
|
||||
self._rows = []
|
||||
self._one = None
|
||||
self.rowcount = 1
|
||||
self.executed = []
|
||||
|
||||
def execute(self, query, *args):
|
||||
@@ -38,7 +56,9 @@ def cur(monkeypatch):
|
||||
def fake_get_cursor():
|
||||
yield c
|
||||
|
||||
monkeypatch.setattr(main, "get_cursor", fake_get_cursor)
|
||||
for module in MODULES_AVEC_CURSEUR:
|
||||
if hasattr(module, "get_cursor"):
|
||||
monkeypatch.setattr(module, "get_cursor", fake_get_cursor)
|
||||
return c
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,70 @@ def test_monitoring_unknown_returns_404(client, cur, auth_headers):
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_create_user_role_invalide_rejete(client, cur, auth_headers):
|
||||
# Le rôle est contraint par l'enum UserRole : Pydantic rejette en 422
|
||||
# avant d'atteindre la base, sans validation manuelle dans la route.
|
||||
r = client.post(
|
||||
"/admin/users",
|
||||
headers=auth_headers,
|
||||
json={"username": "x", "email": "x@x.fr", "password": "p", "role": "Root"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_create_user_role_valide_accepte(client, cur, auth_headers):
|
||||
r = client.post(
|
||||
"/admin/users",
|
||||
headers=auth_headers,
|
||||
json={"username": "x", "email": "x@x.fr", "password": "p", "role": "Superviseur"},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
def test_creation_service_reservee_admin(client, cur):
|
||||
# Un Consultant ne doit pas pouvoir écrire dans le référentiel.
|
||||
token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2})
|
||||
r = client.post(
|
||||
"/services",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"nom_service": "Nouveau"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_creation_service_par_admin(client, cur, auth_headers):
|
||||
cur._one = (42,)
|
||||
r = client.post("/services", headers=auth_headers, json={"nom_service": "Comptabilité"})
|
||||
assert r.status_code == 201
|
||||
assert r.json() == {"id_service": 42, "nom_service": "Comptabilité"}
|
||||
|
||||
|
||||
def test_suppression_service_refusee_si_rattachements(client, cur, auth_headers):
|
||||
# 2 monitorings et 1 contact rattachés : la suppression doit être bloquée
|
||||
# explicitement plutôt que de laisser remonter une violation de clé étrangère.
|
||||
cur._one = (2, 1)
|
||||
r = client.delete("/services/1", headers=auth_headers)
|
||||
assert r.status_code == 409
|
||||
assert "2 monitoring(s)" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_suppression_service_sans_rattachement(client, cur, auth_headers):
|
||||
cur._one = (0, 0)
|
||||
r = client.delete("/services/1", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "deleted"
|
||||
|
||||
|
||||
def test_desactivation_monitoring_ne_supprime_pas(client, cur, auth_headers):
|
||||
# TABLE_FINAL référence id_monito : on désactive, on ne supprime jamais.
|
||||
r = client.delete("/monitorings/1", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "deactivated"
|
||||
requetes = " ".join(q for q, _ in cur.executed)
|
||||
assert "UPDATE NOMENCLATURE_MONITO SET actif = 0" in requetes
|
||||
assert "DELETE FROM NOMENCLATURE_MONITO" not in requetes
|
||||
|
||||
|
||||
def test_admin_forbidden_for_consultant(client):
|
||||
token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2})
|
||||
r = client.get("/admin/users", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
Reference in New Issue
Block a user