Author SHA1 Message Date
AnthoandClaude Opus 5 3538d77217 fix: aligne la version exposee sur la 1.1.0 et documente la config de deploiement
Build & Deploy / build (push) Successful in 26s
L'API se declarait en 1.0.0 (config.py et docs/openapi.json) alors que le
CHANGELOG et le dossier annoncent la 1.1.0 comme version livree : GET /health
et Swagger renvoyaient donc une version fausse.

- API_VERSION 1.0.0 -> 1.1.0 (config.py + docs/openapi.json, spec inchangee
  par ailleurs : 29 chemins, 40 operations)
- ajoute api.env.example, modele de configuration sans valeurs
- ignore api.env : il porte la chaine de connexion et la cle JWT

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:36:11 +02:00
Antho b7551073f5 docs: clarifie l'intitulé de colonne du tableau de tests
Build & Deploy / build (push) Successful in 20s
« Couverture » prêtait à confusion avec les pourcentages de couverture de
code mesurés (README racine du rendu) : la colonne décrit en réalité le
périmètre fonctionnel de chaque fichier de test, pas un taux.
2026-08-16 15:02:06 +02:00
Antho e8b4a604e9 docs: corrige la référence à GitHub dans le RUNBOOK
Build & Deploy / build (push) Successful in 21s
Les dépôts sont hébergés sur Gitea (git.nfteam.ovh), jamais sur GitHub.
2026-08-16 14:56:41 +02:00
Antho caa0b126b1 chore: supprime testAPI.py, script de test manuel devenu inopérant
Build & Deploy / build (push) Successful in 22s
Ce script interrogeait GET /dashboard sans jeton. Depuis l'ajout de
l'authentification JWT, tous les endpoints de données répondent 401 : le
script échoue systématiquement (vérifié sur l'environnement déployé).

Il est par ailleurs entièrement couvert par la suite pytest, qui teste le
même endpoint avec et sans authentification. Le conserver à la racine du
dépôt laissait croire à un outil de diagnostic utilisable.
2026-08-15 15:38:59 +02:00
Antho 75f9727710 merge: intègre test/couverture-api dans dev
Build & Deploy / build (push) Successful in 21s
2026-08-15 15:01:42 +02:00
Antho 138bc3c87c test(api): étend la couverture de 16 à 82 tests
Trois domaines n'étaient pas couverts : l'historique et les courbes
d'évolution, les contrôles de sécurité, et le CRUD des référentiels.

- test_historique.py : bornes de dates passées en paramètres, rejet d'un
  format de date invalide, agrégation des séries par monitoring et par
  service à partir de lignes à plat.
- test_securite.py : protection 401 vérifiée route par route, RBAC 403 pour
  Superviseur et Consultant sur chaque écriture, rejet d'un jeton signé avec
  une autre clé, en-têtes de sécurité, rate-limit 429 à la 6e tentative,
  refus d'un compte désactivé, absence du hachage dans la réponse de login,
  anonymisation RGPD sans suppression de ligne.
- test_referentiels.py : CRUD catégories et contacts, refus 409 sur
  rattachement, 404 sur enregistrement inexistant, modification partielle
  limitée aux champs fournis, réactivation d'un monitoring, plafonnement de
  limit, terme de recherche transmis en paramètre et non concaténé.

Corrige au passage un défaut d'isolation révélé par la suite complète : le
limiteur de débit est un état global, les tests de connexion se
comptabilisaient entre eux et un test échouait selon l'ordre d'exécution
tout en passant fichier par fichier. La fixture limiteur_vierge le remet à
zéro, et un test dédié vérifie désormais explicitement le seuil.

Suite vérifiée stable sur 3 exécutions consécutives et fichier par fichier.
2026-08-15 15:01:42 +02:00
Antho 8bf83d0a7f merge: intègre fix/jwt-secret-et-tracabilite-build dans dev
Build & Deploy / build (push) Successful in 19s
2026-08-15 14:46:50 +02:00
Antho 4bc4770e2d fix(securite): supprime le secret JWT de repli présent dans le dépôt
JWT_SECRET retombait sur "data-sentinel-secret-change-in-prod", une valeur
lisible par quiconque a accès au code : un oubli de configuration suffisait
à permettre de forger un jeton d'administrateur.

Sans JWT_SECRET, une clé aléatoire est désormais tirée au démarrage, avec un
avertissement explicite. Le démarrage n'est volontairement pas bloqué : un
oubli de variable d'environnement ne doit pas transformer une configuration
incomplète en indisponibilité totale du service. Contrepartie assumée et
documentée : les sessions ne survivent pas à un redémarrage tant que la
variable n'est pas définie.

Vérifié : la production signe déjà avec un secret propre, ce correctif ne
change donc rien à son fonctionnement.

Ajoute par ailleurs APP_BUILD, injecté par la CI depuis le SHA du commit et
exposé par GET /health (et /version.json côté front). Jusqu'ici, rien ne
permettait de savoir quelle version tournait réellement : un déploiement
non appliqué était indiscernable d'un déploiement réussi.
2026-08-15 14:46:49 +02:00
Antho cb84e20e9a merge: intègre refactor/normes-xefi-decoupage-routeurs dans dev
Build & Deploy / build (push) Successful in 20s
- fix(config): rend le serveur SQL configurable en développement
- refactor(api): découpe main.py en routeurs par domaine
- feat(api): CRUD des référentiels réservé aux administrateurs
2026-08-15 14:32:11 +02:00
Antho e7b3944436 feat(api): CRUD des référentiels réservé aux administrateurs
L'écran d'administration du front proposait des boutons Modifier et
Désactiver qui n'appelaient rien (console.log côté client), faute
d'endpoints correspondants. Ajoute POST/PUT/DELETE sur /services,
/categories, /contacts et /monitorings, tous protégés par require_admin.

Deux garde-fous métier :
- la suppression d'un service ou d'une catégorie est refusée (409) tant
  que des monitorings ou contacts y sont rattachés, avec le décompte
  dans le message, plutôt que de laisser remonter une violation de clé
  étrangère ;
- un monitoring est désactivé (actif = 0) et jamais supprimé, car
  TABLE_FINAL référence son identifiant et l'historique doit rester
  consultable.

Le rôle utilisateur est désormais typé par l'enum UserRole : Pydantic
le valide seul (422), ce qui supprime les deux contrôles manuels
dupliqués dans create_user et update_user.

Tests : 8 -> 16. Couvre la validation par enum, le refus 409 sur
rattachement, le 403 pour un non-administrateur et la désactivation
logique du monitoring.
2026-08-15 14:28:58 +02:00
Antho 934e669774 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.
2026-08-15 14:28:45 +02:00
Antho 7b3c91509f fix(config): rend le serveur SQL configurable en développement
Le repli de développement pointait en dur sur LaptopCA\SQLEXPRESS,
ce qui rendait l'API inutilisable sur toute autre machine sans éditer
le code. Le serveur vient désormais de DB_SERVER (défaut localhost),
et DB_TRUSTED_CONNECTION permet de forcer l'authentification Windows.

DB_PORT peut rester vide : une instance nommée (MonPoste\SQLEXPRESS)
se joint sans port explicite.
2026-08-15 14:28:34 +02:00
Antho 959b4d76f7 merge: intègre feature/dashboard-filtres-et-comptes-jury dans dev
Build & Deploy / build (push) Successful in 50s
- feat(dashboard): endpoint /dashboard/filtres restreint aux valeurs utilisées
- chore(sql): compte de visite « Juré » + dump d'authentification versionné
2026-08-15 13:35:48 +02:00
27 changed files with 1859 additions and 824 deletions
+4 -1
View File
@@ -16,7 +16,10 @@ jobs:
uses: actions/checkout@v4
- name: Build image
run: docker build -t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" .
run: |
docker build \
--build-arg APP_BUILD="${GITHUB_SHA::12}" \
-t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" .
- name: Tests (pytest dans l'image, BDD simulée)
run: |
+3
View File
@@ -150,6 +150,9 @@ activemq-data/
# Environments
.env
.envrc
# Configuration de déploiement : contient la chaîne de connexion et
# la clé JWT. Seul api.env.example (sans valeurs) est versionné.
api.env
.venv
env/
venv/
+5
View File
@@ -17,5 +17,10 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# SHA du commit construit, injecté par la CI et exposé par GET /health :
# permet de vérifier quelle version tourne réellement après un déploiement.
ARG APP_BUILD=local
ENV APP_BUILD=$APP_BUILD
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+61 -6
View File
@@ -21,18 +21,21 @@ 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) |
| `JWT_SECRET` | clé de signature JWT **obligatoire en déploiement** | clé aléatoire régénérée à chaque démarrage |
| `APP_BUILD` | SHA du commit construit, injecté par la CI et exposé par `/health` | `local` |
| `JWT_ALGORITHM` / `JWT_EXPIRE_MINUTES` | algo / durée du token | `HS256` / `60` |
## Authentification & rôles
@@ -62,18 +65,58 @@ 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).
Spécification complète : `GET /openapi.json` (export dans `docs/openapi.json`).
## Vérifier qu'un déploiement a pris
`GET /health` expose le SHA du commit dont l'image a été construite :
```bash
curl -s https://datasentinel-api.nfteam.ovh/health
# {"api":"ok","database":"ok","version":"1.0.0","build":"cb84e20e1f2a",...}
```
Comparer `build` au dernier commit poussé sur `main`. S'ils diffèrent, le
conteneur tourne encore une ancienne image : `docker compose pull` puis
`docker compose up -d` (un `up -d` seul ne retélécharge rien). Le front expose
la même information sur `/version.json`.
## Sécurité
- `JWT_SECRET` doit être défini en déploiement. À défaut, l'API démarre quand
même mais tire une clé aléatoire à chaque lancement (sessions perdues au
redémarrage) : aucun secret de repli n'est écrit dans le dépôt, un secret
public permettrait de forger un jeton d'administrateur.
- En-têtes : `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Strict-Transport-Security`.
- CORS restreint aux origines `CORS_ORIGINS`, tous verbes + credentials.
- Requêtes SQL **paramétrées** (noms de tables/colonnes whitelistés) ; le compte applicatif
@@ -86,7 +129,19 @@ 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.
**82 tests**, curseur SQL simulé (aucune vraie base requise) :
| Fichier | Domaine testé |
|---------|---------------|
| `test_endpoints.py` | santé, référentiels, authentification, filtres du dashboard, rôles par enum |
| `test_historique.py` | bornes de dates, validation du format, agrégation des courbes par monitoring et par service |
| `test_securite.py` | protection 401 de chaque route, RBAC 403, jeton forgé rejeté, en-têtes de sécurité, rate-limit 429, compte désactivé, droits RGPD |
| `test_referentiels.py` | CRUD complet, refus 409 sur rattachement, modification partielle, paramétrage des requêtes de recherche |
Le compteur anti brute-force est remis à zéro entre les tests (fixture
`limiteur_vierge`) : c'est un état global, et sans cela les tests de connexion
se comptabilisent entre eux et échouent selon l'ordre d'exécution.
## Docker & CI/CD
+1 -1
View File
@@ -77,4 +77,4 @@ docker compose up -d datasentinel-api
## Contacts
- Hébergement / infra : administrateur homelab (neckfire).
- Application / code : A. Coyaud (auteur, dépôts GitHub).
- Application / code : A. Coyaud (auteur, dépôts Gitea — git.nfteam.ovh).
+51
View File
@@ -0,0 +1,51 @@
# ============================================================
# Data Sentinel — modèle de configuration de l'API
#
# Copier ce fichier en api.env (à côté du docker-compose.yml)
# et renseigner les valeurs. api.env n'est JAMAIS versionné :
# il est exclu par .gitignore.
#
# cp Api-DataSentinel/api.env.example api.env
# ============================================================
# ---- Base de données ---------------------------------------
# Hôte SQL Server. En conteneur, le nom du service Docker
# (ex. dev-mssql) ; en local, localhost ou HOTE\INSTANCE.
DB_SERVER=dev-mssql
# Port. Laisser VIDE pour une instance nommée (HOTE\INSTANCE).
DB_PORT=1433
DB_NAME=DataSentinel
# Compte applicatif dédié — surtout pas « sa ».
# Création : voir README.md §3.3 (db_datareader + db_datawriter
# sur la seule base DataSentinel).
DB_USER=datasentinel_app
DB_PASSWORD=
# Pilote ODBC embarqué dans l'image.
DB_DRIVER=ODBC Driver 18 for SQL Server
# Mettre à yes pour forcer l'authentification Windows
# (poste de développement uniquement, sans DB_USER).
DB_TRUSTED_CONNECTION=
# ---- Sécurité ----------------------------------------------
# OBLIGATOIRE en déploiement. Sans cette variable, l'API démarre
# mais tire une clé aléatoire à chaque redémarrage : toutes les
# sessions sont alors invalidées au moindre restart.
# Générer : python -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET=
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=60
# ---- CORS ---------------------------------------------------
# Origines autorisées, séparées par des virgules. Doit contenir
# l'URL exacte du front (schéma + hôte, sans slash final).
CORS_ORIGINS=https://datasentinel.nfteam.ovh
# ---- Build --------------------------------------------------
# Injecté par la CI (SHA court du commit) et exposé par GET /health.
APP_BUILD=local
+67 -22
View File
@@ -3,48 +3,93 @@
# Data Sentinel | COYAUD Anthony | 2026
# ============================================================
import logging
import os
import pyodbc
import secrets
from contextlib import contextmanager
import pyodbc
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;"
)
def _resolve_jwt_secret() -> str:
"""
Clé de signature des jetons.
Elle doit venir de JWT_SECRET. À défaut, on tire une clé aléatoire au
démarrage plutôt que de retomber sur une valeur écrite dans le dépôt :
un secret public permettrait à quiconque lit le code de forger un jeton
d'administrateur.
Conséquence assumée du repli : la clé change à chaque redémarrage, donc
les sessions en cours sont invalidées. C'est visible et sans gravité en
développement, et le message ci-dessous dit quoi faire en déploiement.
On ne bloque volontairement pas le démarrage, pour ne pas transformer un
oubli de configuration en indisponibilité totale du service.
"""
secret = os.getenv("JWT_SECRET")
if secret:
return secret
logging.getLogger("uvicorn.error").warning(
"JWT_SECRET n'est pas defini : une cle aleatoire est generee pour cette "
"execution. Les sessions seront perdues a chaque redemarrage. "
"Definir JWT_SECRET dans l'environnement (api.env) pour un deploiement."
)
return secrets.token_urlsafe(64)
class Config:
# Chaîne de connexion SQL Server (env en prod, Windows en local)
DB_CONNECTION_STRING = _build_connection_string()
# Paramètres API
API_TITLE = "Data Sentinel API"
API_VERSION = "1.0.0"
API_VERSION = "1.1.0"
API_DESCRIPTION = "API de monitoring de la qualité des données — XEFI"
# Identifiant de build injecté par la CI : permet de vérifier quelle
# version tourne réellement après un déploiement (voir GET /health).
BUILD = os.getenv("APP_BUILD", "local")
# Sécurité JWT
SECRET_KEY = os.getenv("JWT_SECRET", "data-sentinel-secret-change-in-prod")
SECRET_KEY = _resolve_jwt_secret()
ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", "60"))
+1 -1
View File
File diff suppressed because one or more lines are too long
+51
View File
@@ -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
View File
@@ -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
)
+38 -745
View File
@@ -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,575 +75,19 @@ 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"])
def health_check():
"""Ping API + test connexion SQL Server."""
"""
Ping API + test connexion SQL Server.
`build` porte le SHA du commit dont l'image a été construite (injecté par
la CI). C'est le seul moyen fiable de vérifier qu'un déploiement a bien
pris : comparer ce champ au dernier commit poussé.
"""
try:
with get_cursor() as cursor:
cursor.execute("SELECT 1")
@@ -650,180 +100,23 @@ def health_check():
"api" : "ok",
"database" : db_status,
"version" : Config.API_VERSION,
"build" : Config.BUILD,
"nb_monitorings" : len(MONITO_TABLES),
}
# ============================================================
# 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)
+11
View File
@@ -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)
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"}
-47
View File
@@ -1,47 +0,0 @@
import requests
import json
# Configuration
BASE_URL = "http://127.0.0.1:8000"
def test_get_dashboard():
print(f"--- Test de l'endpoint: {BASE_URL}/dashboard ---")
try:
# 1. Envoi de la requête
response = requests.get(f"{BASE_URL}/dashboard")
# 2. Vérification du code statut
if response.status_code == 200:
data = response.json()
if not data:
print("L'API a répondu avec succès, mais le tableau est vide (VUE_CONSO ne contient rien).")
return
# 3. Affichage formaté du tableau
print(f"{'ID':<5} | {'NOM DU MONITORING':<40} | {'ERREURS':<8} | {'SERVICE':<15}")
print("-" * 75)
for item in data:
print(f"{item['id_monito']:<5} | {item['nom_monito'][:38]:<40} | {item['nb_erreurs']:<8} | {item['service']:<15}")
print(f"\nTotal de lignes récupérées : {len(data)}")
else:
print(f"Erreur lors de la requête : {response.status_code}")
print(response.text)
except requests.exceptions.ConnectionError:
print("Erreur : Impossible de se connecter à l'API. Est-ce qu'uvicorn est lancé ?")
if __name__ == "__main__":
# Petit check de santé avant de tester les données
print("Vérification de l'état de l'API...")
health = requests.get(f"{BASE_URL}/health").json()
print(f"Statut API : {health['api']} | Statut DB : {health['database']}\n")
if health['database'] == "ok":
test_get_dashboard()
else:
print("Abandon du test : La base de données n'est pas accessible.")
+37 -1
View File
@@ -9,6 +9,24 @@ from fastapi.testclient import TestClient
import main
from auth import create_access_token
from rate_limit import limiter
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 +35,7 @@ class FakeCursor:
self.description = []
self._rows = []
self._one = None
self.rowcount = 1
self.executed = []
def execute(self, query, *args):
@@ -38,10 +57,27 @@ 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
@pytest.fixture(autouse=True)
def limiteur_vierge():
"""
Remet le compteur anti brute-force à zéro entre les tests.
Le limiteur est un état global partagé par toute l'application : sans
remise à zéro, les tests de connexion se comptabilisent entre eux et
finissent par recevoir un 429, avec des échecs qui dépendent de l'ordre
d'exécution et ne se reproduisent pas fichier par fichier.
"""
limiter.reset()
yield
limiter.reset()
@pytest.fixture
def client():
return TestClient(main.app)
+64
View File
@@ -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}"})
+111
View File
@@ -0,0 +1,111 @@
"""Historique et courbes d'évolution (curseur SQL simulé via la fixture `cur`)."""
def _requetes(cur) -> str:
"""Concatène les requêtes exécutées, pour vérifier le SQL construit."""
return " ".join(q for q, _ in cur.executed)
def _parametres(cur) -> list:
return [p for _, args in cur.executed for p in args]
def test_historique_exige_authentification(client):
assert client.get("/historique").status_code == 401
def test_historique_sans_filtre_ne_restreint_rien(client, cur, auth_headers):
cur.description = [("id_monito",), ("nb_erreurs",)]
cur._rows = [(1, 5)]
r = client.get("/historique", headers=auth_headers)
assert r.status_code == 200
requete = _requetes(cur)
assert "date_sauvegarde >=" not in requete
assert "date_sauvegarde <=" not in requete
def test_historique_applique_les_bornes_de_dates(client, cur, auth_headers):
cur.description = [("id_monito",)]
cur._rows = []
r = client.get(
"/historique",
params={"date_debut": "2026-01-01", "date_fin": "2026-08-15"},
headers=auth_headers,
)
assert r.status_code == 200
requete = _requetes(cur)
assert "date_sauvegarde >= ?" in requete
assert "date_sauvegarde <= ?" in requete
# Les dates sont passées en paramètres, jamais concaténées dans le SQL.
assert "2026-01-01" in _parametres(cur)
assert "2026-08-15" in _parametres(cur)
def test_historique_refuse_une_date_mal_formee(client, cur, auth_headers):
r = client.get("/historique", params={"date_debut": "15/01/2026"}, headers=auth_headers)
assert r.status_code == 422
def test_historique_filtre_par_monitoring(client, cur, auth_headers):
cur.description = [("id_monito",)]
cur._rows = []
client.get("/historique", params={"id_monito": 3}, headers=auth_headers)
assert "id_monito = ?" in _requetes(cur)
assert 3 in _parametres(cur)
def test_evolution_monitoring_inconnu_renvoie_404(client, cur, auth_headers):
# 999 n'est pas dans MONITO_TABLES : on refuse avant d'interroger la base.
r = client.get("/historique/999/evolution", headers=auth_headers)
assert r.status_code == 404
def test_evolution_formate_les_points_pour_le_graphique(client, cur, auth_headers):
cur._rows = [("2026-01-01", 12), ("2026-01-02", 9)]
r = client.get("/historique/1/evolution", headers=auth_headers)
assert r.status_code == 200
corps = r.json()
assert corps["points"] == 2
assert corps["evolution"][0] == {"date": "2026-01-01", "nb_erreurs": 12}
def test_evolution_globale_renvoie_une_serie(client, cur, auth_headers):
cur._rows = [("2026-01-01", 56), ("2026-01-02", 54)]
r = client.get("/evolution/global", headers=auth_headers)
assert r.status_code == 200
corps = r.json()
assert corps["type"] == "GLOBAL"
assert corps["points"] == 2
assert corps["series"][0]["nb_erreurs"] == 56
def test_evolution_par_monitoring_regroupe_par_courbe(client, cur, auth_headers):
# Lignes à plat en base -> une série par monitoring côté API.
cur._rows = [
("2026-01-01", 1, "Tiers payeurs", 5),
("2026-01-02", 1, "Tiers payeurs", 4),
("2026-01-01", 2, "Sans RIB", 6),
]
r = client.get("/evolution/par-monitoring", headers=auth_headers)
assert r.status_code == 200
corps = r.json()
assert corps["nb_series"] == 2
series = {m["id_monito"]: m for m in corps["monitorings"]}
assert len(series[1]["serie"]) == 2
assert len(series[2]["serie"]) == 1
assert series[1]["nom_monito"] == "Tiers payeurs"
def test_evolution_par_service_regroupe_par_service(client, cur, auth_headers):
cur._rows = [
("2026-01-01", "Contrat", 20),
("2026-01-02", "Contrat", 18),
("2026-01-01", "Fournisseur", 5),
]
r = client.get("/evolution/par-service", headers=auth_headers)
assert r.status_code == 200
corps = r.json()
assert corps["nb_series"] == 2
par_service = {s["service"]: s["serie"] for s in corps["services"]}
assert len(par_service["Contrat"]) == 2
assert len(par_service["Fournisseur"]) == 1
+143
View File
@@ -0,0 +1,143 @@
"""CRUD des référentiels : catégories, contacts et nomenclature."""
def _requetes(cur) -> str:
return " ".join(q for q, _ in cur.executed)
def _parametres(cur) -> list:
return [p for _, args in cur.executed for p in args]
# ------------------------------------------------------------
# Catégories
# ------------------------------------------------------------
def test_creation_categorie(client, cur, auth_headers):
cur._one = (7,)
r = client.post("/categories", headers=auth_headers,
json={"intitule_categorie": "Litiges"})
assert r.status_code == 201
assert r.json() == {"id_categorie": 7, "intitule_categorie": "Litiges"}
def test_categorie_intitule_vide_refuse(client, cur, auth_headers):
# Contrainte min_length : on ne crée pas un référentiel sans libellé.
r = client.post("/categories", headers=auth_headers, json={"intitule_categorie": ""})
assert r.status_code == 422
def test_suppression_categorie_refusee_si_monitorings_rattaches(client, cur, auth_headers):
cur._one = (3,)
r = client.delete("/categories/1", headers=auth_headers)
assert r.status_code == 409
assert "3 monitoring(s)" in r.json()["detail"]
# Aucune suppression ne doit avoir été tentée.
assert "DELETE FROM CATEGORIE" not in _requetes(cur)
def test_suppression_categorie_libre_autorisee(client, cur, auth_headers):
cur._one = (0,)
r = client.delete("/categories/1", headers=auth_headers)
assert r.status_code == 200
assert "DELETE FROM CATEGORIE" in _requetes(cur)
def test_modification_categorie_inexistante_renvoie_404(client, cur, auth_headers):
cur.rowcount = 0
r = client.put("/categories/999", headers=auth_headers,
json={"intitule_categorie": "X"})
assert r.status_code == 404
# ------------------------------------------------------------
# Contacts
# ------------------------------------------------------------
def test_creation_contact(client, cur, auth_headers):
cur._one = (12,)
r = client.post("/contacts", headers=auth_headers, json={
"id_service": 1, "intitule_contact": "Référent",
"nom": "Dupont", "prenom": "Marie", "mail": "marie@xefi-fictif.fr",
})
assert r.status_code == 201
assert r.json()["id_contact"] == 12
assert r.json()["nom"] == "Dupont"
def test_contact_champ_manquant_refuse(client, cur, auth_headers):
r = client.post("/contacts", headers=auth_headers, json={"nom": "Dupont"})
assert r.status_code == 422
def test_suppression_contact_directe(client, cur, auth_headers):
# Aucune table ne référence un contact : suppression sans garde-fou.
r = client.delete("/contacts/1", headers=auth_headers)
assert r.status_code == 200
assert "DELETE FROM CONTACT" in _requetes(cur)
def test_suppression_contact_inexistant_renvoie_404(client, cur, auth_headers):
cur.rowcount = 0
assert client.delete("/contacts/999", headers=auth_headers).status_code == 404
# ------------------------------------------------------------
# Nomenclature des monitorings
# ------------------------------------------------------------
def test_creation_monitoring_actif_par_defaut(client, cur, auth_headers):
cur._one = (8,)
r = client.post("/monitorings", headers=auth_headers, json={
"monito_intitule": "Nouveau", "id_service": 1, "id_categorie": 1,
"table_source": "MONITO_TEST", "bdd_source": "Sage",
})
assert r.status_code == 201
assert "actif" in _requetes(cur)
def test_modification_partielle_ne_touche_que_les_champs_fournis(client, cur, auth_headers):
r = client.put("/monitorings/1", headers=auth_headers,
json={"monito_intitule": "Renommé"})
assert r.status_code == 200
requete = _requetes(cur)
assert "monito_intitule = ?" in requete
assert "bdd_source = ?" not in requete
def test_modification_sans_aucun_champ_refusee(client, cur, auth_headers):
r = client.put("/monitorings/1", headers=auth_headers, json={})
assert r.status_code == 400
def test_reactivation_possible_par_modification(client, cur, auth_headers):
# Permet de revenir sur une désactivation.
r = client.put("/monitorings/7", headers=auth_headers, json={"actif": True})
assert r.status_code == 200
assert "actif = ?" in _requetes(cur)
assert 1 in _parametres(cur)
def test_desactivation_monitoring_inexistant_renvoie_404(client, cur, auth_headers):
cur.rowcount = 0
assert client.delete("/monitorings/999", headers=auth_headers).status_code == 404
def test_details_monitoring_recherche_sur_les_colonnes_declarees(client, cur, auth_headers):
cur.description = [("id_ligne",)]
cur._rows = []
r = client.get("/monitorings/1/details", params={"search": "Lyon"}, headers=auth_headers)
assert r.status_code == 200
requete = _requetes(cur)
assert "LIKE ?" in requete
# Le terme est passé en paramètre, jamais injecté dans la requête.
assert "%Lyon%" in _parametres(cur)
assert "Lyon" not in requete
def test_details_monitoring_limite_bornee(client, cur, auth_headers):
# limit est plafonné à 5000 pour éviter un export non maîtrisé.
assert client.get("/monitorings/1/details", params={"limit": 99999},
headers=auth_headers).status_code == 422
assert client.get("/monitorings/1/details", params={"limit": 0},
headers=auth_headers).status_code == 422
+159
View File
@@ -0,0 +1,159 @@
"""Contrôles d'accès, en-têtes de sécurité et droits RGPD."""
import pytest
from auth import create_access_token, hash_password
from domain import UserRole
def _token(role: str, username: str = "u", uid: int = 1) -> dict:
return {"Authorization": f"Bearer {create_access_token(
{'sub': username, 'role': role, 'uid': uid})}"}
# ------------------------------------------------------------
# Protection des routes de données
# ------------------------------------------------------------
@pytest.mark.parametrize("route", [
"/categories", "/services", "/contacts", "/monitorings",
"/dashboard", "/dashboard/filtres", "/dashboard/summary",
"/historique", "/evolution/global", "/admin/users", "/me/data-export",
])
def test_toutes_les_routes_de_donnees_exigent_un_jeton(client, route):
assert client.get(route).status_code == 401
def test_health_reste_public(client, cur):
cur._one = (1,)
assert client.get("/health").status_code == 200
def test_jeton_invalide_rejete(client):
r = client.get("/services", headers={"Authorization": "Bearer pas-un-vrai-jeton"})
assert r.status_code == 401
def test_jeton_signe_avec_une_autre_cle_rejete(client):
# Un jeton forgé avec un autre secret ne doit pas ouvrir l'accès :
# c'est ce que garantissait mal un secret de repli écrit dans le dépôt.
from jose import jwt
faux = jwt.encode({"sub": "pirate", "role": "Admin", "uid": 99},
"mauvaise-cle", algorithm="HS256")
r = client.get("/admin/users", headers={"Authorization": f"Bearer {faux}"})
assert r.status_code == 401
# ------------------------------------------------------------
# RBAC : seuls les administrateurs écrivent
# ------------------------------------------------------------
@pytest.mark.parametrize("role", [UserRole.SUPERVISEUR.value, UserRole.CONSULTANT.value])
@pytest.mark.parametrize("methode,route,corps", [
("post", "/services", {"nom_service": "X"}),
("put", "/services/1", {"nom_service": "X"}),
("delete", "/services/1", None),
("post", "/categories", {"intitule_categorie": "X"}),
("post", "/monitorings", {"monito_intitule": "X", "id_service": 1,
"id_categorie": 1, "table_source": "T", "bdd_source": "Sage"}),
("get", "/admin/users", None),
("get", "/admin/journal", None),
])
def test_ecriture_interdite_hors_admin(client, cur, role, methode, route, corps):
appel = getattr(client, methode)
reponse = appel(route, headers=_token(role), json=corps) if corps else appel(route, headers=_token(role))
assert reponse.status_code == 403
def test_lecture_autorisee_pour_un_consultant(client, cur):
cur.description = [("id_service",), ("nom_service",)]
cur._rows = [(1, "Contrat")]
r = client.get("/services", headers=_token(UserRole.CONSULTANT.value))
assert r.status_code == 200
# ------------------------------------------------------------
# En-têtes de sécurité
# ------------------------------------------------------------
@pytest.mark.parametrize("entete,valeur", [
("X-Content-Type-Options", "nosniff"),
("X-Frame-Options", "DENY"),
("Referrer-Policy", "strict-origin-when-cross-origin"),
])
def test_entetes_de_securite_presents(client, cur, entete, valeur):
cur._one = (1,)
assert client.get("/health").headers[entete] == valeur
def test_strict_transport_security_present(client, cur):
cur._one = (1,)
assert "max-age=" in client.get("/health").headers["Strict-Transport-Security"]
# ------------------------------------------------------------
# Authentification
# ------------------------------------------------------------
def test_compte_desactive_ne_peut_pas_se_connecter(client, cur):
# actif = 0 : le mot de passe est correct mais l'accès doit être refusé.
cur._one = (1, "ancien", hash_password("Bon2026!"), "Admin", 0)
r = client.post("/auth/login", data={"username": "ancien", "password": "Bon2026!"})
assert r.status_code == 401
def test_utilisateur_inconnu_renvoie_401(client, cur):
cur._one = None
r = client.post("/auth/login", data={"username": "fantome", "password": "x"})
assert r.status_code == 401
def test_le_mot_de_passe_nest_jamais_renvoye(client, cur):
cur._one = (1, "admin", hash_password("Admin2026!"), "Admin", 1)
r = client.post("/auth/login", data={"username": "admin", "password": "Admin2026!"})
assert r.status_code == 200
assert "password" not in r.text
assert "hash" not in r.text
def test_rate_limit_bloque_le_brute_force(client, cur):
"""La 6e tentative en moins d'une minute doit être refusée (5/minute)."""
cur._one = (1, "admin", hash_password("Admin2026!"), "Admin", 1)
codes = [
client.post("/auth/login", data={"username": "admin", "password": "faux"}).status_code
for _ in range(6)
]
assert codes[:5] == [401] * 5
assert codes[5] == 429
def test_connexion_journalisee(client, cur):
cur._one = (1, "admin", hash_password("Admin2026!"), "Admin", 1)
client.post("/auth/login", data={"username": "admin", "password": "Admin2026!"})
requetes = " ".join(q for q, _ in cur.executed)
assert "INSERT INTO JOURNAL_AUDIT" in requetes
# L'action passe en paramètre, pas concaténée dans la requête.
assert "LOGIN" in [p for _, args in cur.executed for p in args]
# ------------------------------------------------------------
# RGPD
# ------------------------------------------------------------
def test_export_de_mes_donnees_retourne_profil_et_journal(client, cur, auth_headers):
cur.description = [("id_user",), ("username",)]
cur._one = (1, "admin")
cur._rows = [(1, "admin")]
r = client.get("/me/data-export", headers=auth_headers)
assert r.status_code == 200
assert set(r.json()) == {"profile", "journal"}
def test_suppression_de_compte_anonymise_au_lieu_de_supprimer(client, cur, auth_headers):
# Droit à l'oubli : le compte est désactivé et anonymisé, la ligne reste
# pour préserver l'intégrité du journal d'audit.
r = client.delete("/me", headers=auth_headers)
assert r.status_code == 200
requetes = " ".join(q for q, _ in cur.executed)
assert "UPDATE [USER]" in requetes
assert "DELETE FROM [USER]" not in requetes
assert "JOURNAL_AUDIT SET username = NULL" in requetes