refactor(api): découpe main.py en routeurs par domaine

main.py comptait 829 lignes et regroupait 9 domaines fonctionnels.
Il ne fait plus que l'assemblage de l'application (configuration,
middlewares, montage des routeurs), soit 115 lignes.

- routers/ : un module par domaine, chacun déclarant sa propre
  dépendance d'authentification
- domain.py : enums UserRole et AuditAction, mapping MONITO_TABLES ;
  les rôles étaient jusqu'ici répétés en dur à deux endroits
- helpers.py : conversion des lignes pyodbc, écriture du journal
- rate_limit.py : limiteur partagé, isolé pour éviter un import
  circulaire entre main.py et le routeur d'authentification

Les codes HTTP littéraux (404, 401, 400, 201) passent aux constantes
fastapi.status, comme le faisait déjà auth.py. Les actions du journal
d'audit passent en paramètre SQL au lieu d'être concaténées.

Aucune route modifiée : la comparaison des specs OpenAPI avant/après
confirme que les 26 URL existantes sont identiques.

conftest patchait main.get_cursor ; chaque routeur important désormais
get_cursor dans son propre espace de noms, la fixture remplace le nom
dans tous les modules concernés.
This commit is contained in:
2026-08-15 14:28:45 +02:00
parent 7b3c91509f
commit 934e669774
14 changed files with 1165 additions and 745 deletions
View File
+104
View File
@@ -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())
+47
View File
@@ -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
+91
View File
@@ -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
+158
View File
@@ -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()],
}
+110
View File
@@ -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}
+215
View File
@@ -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()),
}
+233
View File
@@ -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}
+43
View File
@@ -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"}