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:
@@ -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()),
|
||||
}
|
||||
Reference in New Issue
Block a user