Compare commits
15
Commits
66436629af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3538d77217 | ||
|
|
b7551073f5 | ||
|
|
e8b4a604e9 | ||
|
|
caa0b126b1 | ||
|
|
75f9727710 | ||
|
|
138bc3c87c | ||
|
|
8bf83d0a7f | ||
|
|
4bc4770e2d | ||
|
|
cb84e20e9a | ||
|
|
e7b3944436 | ||
|
|
934e669774 | ||
|
|
7b3c91509f | ||
|
|
959b4d76f7 | ||
|
|
e6afc4f1fa | ||
|
|
fd4c085ee4 |
@@ -16,7 +16,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build image
|
- 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)
|
- name: Tests (pytest dans l'image, BDD simulée)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ activemq-data/
|
|||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.envrc
|
.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
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
|||||||
@@ -17,5 +17,10 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
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
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -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`
|
- 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
|
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)
|
## Configuration (variables d'environnement)
|
||||||
|
|
||||||
| Variable | Rôle | Défaut |
|
| Variable | Rôle | Défaut |
|
||||||
|----------|------|--------|
|
|----------|------|--------|
|
||||||
| `DB_SERVER` | hôte SQL Server (active l'auth SQL si défini) | — (sinon Windows local) |
|
| `DB_SERVER` | hôte SQL Server (ou `hôte\instance`) | `localhost` |
|
||||||
| `DB_PORT` / `DB_NAME` | port / base | `1433` / `DataSentinel` |
|
| `DB_PORT` / `DB_NAME` | port / base — laisser `DB_PORT` vide pour une instance nommée | — / `DataSentinel` |
|
||||||
| `DB_USER` / `DB_PASSWORD` | compte applicatif | — |
|
| `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` |
|
| `DB_DRIVER` | pilote ODBC | `ODBC Driver 18 for SQL Server` |
|
||||||
| `CORS_ORIGINS` | origines autorisées (séparées par `,`) | `localhost:5173,localhost:3000` |
|
| `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` |
|
| `JWT_ALGORITHM` / `JWT_EXPIRE_MINUTES` | algo / durée du token | `HS256` / `60` |
|
||||||
|
|
||||||
## Authentification & rôles
|
## Authentification & rôles
|
||||||
@@ -44,28 +47,81 @@ Sans variables d'environnement, l'API se connecte en authentification Windows
|
|||||||
|
|
||||||
### Comptes de démo
|
### Comptes de démo
|
||||||
|
|
||||||
| Identifiant | Mot de passe | Rôle |
|
| Identifiant | Mot de passe | Rôle | Usage |
|
||||||
|-------------|--------------|------|
|
|-------------|--------------|------|-------|
|
||||||
| `admin` | `Admin2026!` | Admin |
|
| `Juré` (`JURE@NEXA.com`) | `123456` | Admin | **Compte de visite / évaluation** — à communiquer aux personnes qui consultent le site |
|
||||||
| `superviseur` | `Super2026!` | Superviseur |
|
| `admin` | `Admin2026!` | Admin | Compte d'administration technique |
|
||||||
| `consultant` | `Conseil2026!` | Consultant |
|
| `superviseur` | `Super2026!` | Superviseur | Illustration du RBAC (pas d'accès `/admin/*`) |
|
||||||
|
| `consultant` | `Conseil2026!` | Consultant | Illustration du RBAC (lecture seule) |
|
||||||
|
|
||||||
|
Ces comptes sont créés par `sql/data_sentinel_auth.sql`. Pour (re)créer le seul
|
||||||
|
compte `Juré` sur une base déjà déployée, sans rejouer tout le script d'auth :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlcmd -S <serveur> -d DataSentinel -U <user> -P <mdp> -i sql/create_admin_jure.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
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é)
|
## Endpoints (résumé)
|
||||||
|
|
||||||
- **Données** (protégés) : `/categories`, `/services`, `/contacts`, `/monitorings[...]`,
|
- **Données** (protégés) : `/categories`, `/services`, `/contacts`, `/monitorings[...]`,
|
||||||
`/dashboard[...]`, `/historique[...]`, `/evolution/*`.
|
`/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}`,
|
- **Admin** (`Admin`) : `GET/POST /admin/users`, `PUT/DELETE /admin/users/{id}`,
|
||||||
`POST /admin/users/{id}/reset-password`, `GET /admin/journal`.
|
`POST /admin/users/{id}/reset-password`, `GET /admin/journal`.
|
||||||
- **RGPD** : `GET /me/data-export` (portabilité), `DELETE /me` (droit à l'oubli + anonymisation).
|
- **RGPD** : `GET /me/data-export` (portabilité), `DELETE /me` (droit à l'oubli + anonymisation).
|
||||||
|
|
||||||
Spécification complète : `GET /openapi.json` (export dans `docs/openapi.json`).
|
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é
|
## 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`.
|
- En-têtes : `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Strict-Transport-Security`.
|
||||||
- CORS restreint aux origines `CORS_ORIGINS`, tous verbes + credentials.
|
- CORS restreint aux origines `CORS_ORIGINS`, tous verbes + credentials.
|
||||||
- Requêtes SQL **paramétrées** (noms de tables/colonnes whitelistés) ; le compte applicatif
|
- Requêtes SQL **paramétrées** (noms de tables/colonnes whitelistés) ; le compte applicatif
|
||||||
n'est pas `sa`. Tables d'auth : `[USER]` + `JOURNAL_AUDIT` (`sql/data_sentinel_auth.sql`).
|
n'est pas `sa`. Tables d'auth : `[USER]` + `JOURNAL_AUDIT` (`sql/data_sentinel_auth.sql`,
|
||||||
|
identique au dump livré dans `RENDU/02_Dump_SQL/`).
|
||||||
- Journal d'audit alimenté à chaque login + action admin.
|
- Journal d'audit alimenté à chaque login + action admin.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
@@ -73,7 +129,19 @@ Spécification complète : `GET /openapi.json` (export dans `docs/openapi.json`)
|
|||||||
```bash
|
```bash
|
||||||
pip install pytest httpx && pytest -q
|
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
|
## Docker & CI/CD
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -77,4 +77,4 @@ docker compose up -d datasentinel-api
|
|||||||
## Contacts
|
## Contacts
|
||||||
|
|
||||||
- Hébergement / infra : administrateur homelab (neckfire).
|
- 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).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,48 +3,93 @@
|
|||||||
# Data Sentinel | COYAUD Anthony | 2026
|
# Data Sentinel | COYAUD Anthony | 2026
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import pyodbc
|
import secrets
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import pyodbc
|
||||||
|
|
||||||
|
|
||||||
def _build_connection_string() -> str:
|
def _build_connection_string() -> str:
|
||||||
"""
|
"""
|
||||||
En déploiement (variables d'environnement présentes), on se connecte en
|
Construit la chaîne de connexion ODBC à partir des variables
|
||||||
authentification SQL. Sinon, on garde la connexion Windows locale par défaut
|
d'environnement.
|
||||||
(poste de dev).
|
|
||||||
|
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")
|
server = os.getenv("DB_SERVER", "localhost")
|
||||||
if server:
|
port = os.getenv("DB_PORT", "")
|
||||||
driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server")
|
base = os.getenv("DB_NAME", "DataSentinel")
|
||||||
port = os.getenv("DB_PORT", "1433")
|
driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server")
|
||||||
return (
|
user = os.getenv("DB_USER")
|
||||||
f"Driver={{{driver}}};"
|
|
||||||
f"Server={server},{port};"
|
# Une instance nommée (MonPoste\SQLEXPRESS) se joint sans port explicite.
|
||||||
f"Database={os.getenv('DB_NAME', 'DataSentinel')};"
|
adresse = f"{server},{port}" if port else server
|
||||||
f"UID={os.getenv('DB_USER')};"
|
|
||||||
f"PWD={os.getenv('DB_PASSWORD')};"
|
trusted = os.getenv("DB_TRUSTED_CONNECTION", "").lower() in ("1", "yes", "true")
|
||||||
"Encrypt=yes;TrustServerCertificate=yes;"
|
if user and not trusted:
|
||||||
)
|
identification = f"UID={user};PWD={os.getenv('DB_PASSWORD', '')};"
|
||||||
|
else:
|
||||||
|
identification = "Trusted_Connection=yes;"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"Driver={ODBC Driver 17 for SQL Server};"
|
f"Driver={{{driver}}};"
|
||||||
"Server=LaptopCA\\SQLEXPRESS;"
|
f"Server={adresse};"
|
||||||
"Database=DataSentinel;"
|
f"Database={base};"
|
||||||
"Trusted_Connection=yes;"
|
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:
|
class Config:
|
||||||
# Chaîne de connexion SQL Server (env en prod, Windows en local)
|
# Chaîne de connexion SQL Server (env en prod, Windows en local)
|
||||||
DB_CONNECTION_STRING = _build_connection_string()
|
DB_CONNECTION_STRING = _build_connection_string()
|
||||||
|
|
||||||
# Paramètres API
|
# Paramètres API
|
||||||
API_TITLE = "Data Sentinel 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"
|
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
|
# 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")
|
ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||||||
TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", "60"))
|
TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", "60"))
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,51 @@
|
|||||||
|
# ============================================================
|
||||||
|
# domain.py — Constantes et énumérations du domaine Data Sentinel
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, Enum):
|
||||||
|
"""Rôles applicatifs — miroir de la contrainte CK_USER_ROLE en base."""
|
||||||
|
ADMIN = "Admin"
|
||||||
|
SUPERVISEUR = "Superviseur"
|
||||||
|
CONSULTANT = "Consultant"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAction(str, Enum):
|
||||||
|
"""Actions tracées dans JOURNAL_AUDIT."""
|
||||||
|
LOGIN = "LOGIN"
|
||||||
|
CREATE_USER = "CREATE_USER"
|
||||||
|
UPDATE_USER = "UPDATE_USER"
|
||||||
|
DELETE_USER = "DELETE_USER"
|
||||||
|
RESET_PASSWORD = "RESET_PASSWORD"
|
||||||
|
CREATE_REFERENTIEL = "CREATE_REFERENTIEL"
|
||||||
|
UPDATE_REFERENTIEL = "UPDATE_REFERENTIEL"
|
||||||
|
DELETE_REFERENTIEL = "DELETE_REFERENTIEL"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Mapping id_monito → table SQL dédiée
|
||||||
|
# Pour ajouter un monitoring : ajouter une entrée ici.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
MONITO_TABLES: dict[int, str] = {
|
||||||
|
1: "MONITO_TIERS_PAYEURS",
|
||||||
|
2: "MONITO_PRELEVEMENT_SANS_RIB",
|
||||||
|
3: "MONITO_DOM_TOM_TVA",
|
||||||
|
4: "MONITO_ZONE_VENTES_CRM",
|
||||||
|
5: "MONITO_MULTI_MODES_REGLEMENT",
|
||||||
|
6: "MONITO_SANS_SIRET",
|
||||||
|
7: "MONITO_SANS_CONTREPARTIE",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Colonnes de recherche textuelle par table (pour le filtre search)
|
||||||
|
SEARCH_COLS: dict[str, list[str]] = {
|
||||||
|
"MONITO_TIERS_PAYEURS": ["CT_Intitule", "origineClient", "STE"],
|
||||||
|
"MONITO_PRELEVEMENT_SANS_RIB": ["CT_Intitule", "STE"],
|
||||||
|
"MONITO_DOM_TOM_TVA": ["CT_Intitule", "agence", "CT_Pays"],
|
||||||
|
"MONITO_ZONE_VENTES_CRM": ["name", "Agence", "xefi_sagedatabase"],
|
||||||
|
"MONITO_MULTI_MODES_REGLEMENT": ["CT_Intitule", "Agence"],
|
||||||
|
"MONITO_SANS_SIRET": ["ct_intitule", "agence", "ct_num"],
|
||||||
|
"MONITO_SANS_CONTREPARTIE": ["agence", "EC_Piece", "CT_NumCont"],
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
# ============================================================
|
||||||
|
# helpers.py — Conversion des résultats pyodbc et journal d'audit
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from domain import MONITO_TABLES, AuditAction
|
||||||
|
|
||||||
|
|
||||||
|
def rows_to_list(cursor, rows) -> list[dict]:
|
||||||
|
"""Convertit les lignes pyodbc en liste de dicts."""
|
||||||
|
cols = [col[0] for col in cursor.description]
|
||||||
|
return [dict(zip(cols, r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def row_to_dict(cursor, row) -> dict:
|
||||||
|
"""Convertit une ligne pyodbc en dict."""
|
||||||
|
cols = [col[0] for col in cursor.description]
|
||||||
|
return dict(zip(cols, row))
|
||||||
|
|
||||||
|
|
||||||
|
def get_table_name(id_monito: int) -> str:
|
||||||
|
"""Retourne le nom de la table dédiée. Lève 404 si inconnu."""
|
||||||
|
table = MONITO_TABLES.get(id_monito)
|
||||||
|
if not table:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Monitoring {id_monito} introuvable. IDs valides : {list(MONITO_TABLES.keys())}"
|
||||||
|
)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_row(row: dict) -> dict:
|
||||||
|
"""Convertit les types non-JSON (date, Decimal) en types sérialisables."""
|
||||||
|
result = {}
|
||||||
|
for k, v in row.items():
|
||||||
|
if v is None:
|
||||||
|
result[k] = None
|
||||||
|
elif hasattr(v, 'isoformat'):
|
||||||
|
result[k] = v.isoformat()
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def journaliser(cursor, acteur: dict, action: AuditAction, detail: str) -> None:
|
||||||
|
"""Trace une action d'administration dans JOURNAL_AUDIT."""
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, ?, ?)",
|
||||||
|
acteur["id_user"], acteur["username"], action.value, detail
|
||||||
|
)
|
||||||
@@ -3,27 +3,34 @@
|
|||||||
# COYAUD Anthony | 2026
|
# COYAUD Anthony | 2026
|
||||||
# V2 : une table dédiée par monitoring
|
# 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
|
# Lancement : uvicorn main:app --reload --port 8000
|
||||||
# Swagger : http://localhost:8000/docs
|
# Swagger : http://localhost:8000/docs
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query, APIRouter, Depends, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 import _rate_limit_exceeded_handler
|
||||||
from slowapi.util import get_remote_address
|
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
from config import Config, get_cursor
|
from config import Config, get_cursor
|
||||||
from auth import (
|
from domain import MONITO_TABLES
|
||||||
verify_password, hash_password, create_access_token,
|
from rate_limit import limiter
|
||||||
get_current_user, require_admin,
|
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,
|
# 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).
|
# Limiteur de débit (anti brute-force sur /auth/login).
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
|
||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
@@ -69,548 +75,19 @@ async def security_headers(request: Request, call_next):
|
|||||||
return response
|
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
|
# SANTÉ — route publique
|
||||||
# Pour ajouter un monitoring : ajouter une entrée ici.
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
MONITO_TABLES: dict[int, str] = {
|
|
||||||
1: "MONITO_TIERS_PAYEURS",
|
|
||||||
2: "MONITO_PRELEVEMENT_SANS_RIB",
|
|
||||||
3: "MONITO_DOM_TOM_TVA",
|
|
||||||
4: "MONITO_ZONE_VENTES_CRM",
|
|
||||||
5: "MONITO_MULTI_MODES_REGLEMENT",
|
|
||||||
6: "MONITO_SANS_SIRET",
|
|
||||||
7: "MONITO_SANS_CONTREPARTIE",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Colonnes de recherche textuelle par table (pour le filtre search)
|
|
||||||
SEARCH_COLS: dict[str, list[str]] = {
|
|
||||||
"MONITO_TIERS_PAYEURS": ["CT_Intitule", "origineClient", "STE"],
|
|
||||||
"MONITO_PRELEVEMENT_SANS_RIB": ["CT_Intitule", "STE"],
|
|
||||||
"MONITO_DOM_TOM_TVA": ["CT_Intitule", "agence", "CT_Pays"],
|
|
||||||
"MONITO_ZONE_VENTES_CRM": ["name", "Agence", "xefi_sagedatabase"],
|
|
||||||
"MONITO_MULTI_MODES_REGLEMENT": ["CT_Intitule", "Agence"],
|
|
||||||
"MONITO_SANS_SIRET": ["ct_intitule", "agence", "ct_num"],
|
|
||||||
"MONITO_SANS_CONTREPARTIE": ["agence", "EC_Piece", "CT_NumCont"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Helpers
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
def rows_to_list(cursor, rows) -> list[dict]:
|
|
||||||
"""Convertit les lignes pyodbc en liste de dicts."""
|
|
||||||
cols = [col[0] for col in cursor.description]
|
|
||||||
return [dict(zip(cols, r)) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def row_to_dict(cursor, row) -> dict:
|
|
||||||
"""Convertit une ligne pyodbc en dict."""
|
|
||||||
cols = [col[0] for col in cursor.description]
|
|
||||||
return dict(zip(cols, row))
|
|
||||||
|
|
||||||
|
|
||||||
def get_table_name(id_monito: int) -> str:
|
|
||||||
"""Retourne le nom de la table dédiée. Lève 404 si inconnu."""
|
|
||||||
table = MONITO_TABLES.get(id_monito)
|
|
||||||
if not table:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"Monitoring {id_monito} introuvable. IDs valides : {list(MONITO_TABLES.keys())}"
|
|
||||||
)
|
|
||||||
return table
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_row(row: dict) -> dict:
|
|
||||||
"""Convertit les types non-JSON (date, Decimal) en types sérialisables."""
|
|
||||||
result = {}
|
|
||||||
for k, v in row.items():
|
|
||||||
if v is None:
|
|
||||||
result[k] = None
|
|
||||||
elif hasattr(v, 'isoformat'):
|
|
||||||
result[k] = v.isoformat()
|
|
||||||
else:
|
|
||||||
result[k] = v
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# RÉFÉRENTIELS
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
@router.get("/categories", tags=["Référentiels"])
|
|
||||||
def get_categories():
|
|
||||||
"""Toutes les catégories de monitoring."""
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT id_categorie, intitule_categorie "
|
|
||||||
"FROM CATEGORIE ORDER BY intitule_categorie"
|
|
||||||
)
|
|
||||||
return rows_to_list(cursor, cursor.fetchall())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/services", tags=["Référentiels"])
|
|
||||||
def get_services():
|
|
||||||
"""Tous les services."""
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute("SELECT id_service, nom_service FROM SERVICE ORDER BY nom_service")
|
|
||||||
return rows_to_list(cursor, cursor.fetchall())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts", tags=["Référentiels"])
|
|
||||||
def get_contacts(
|
|
||||||
id_service: Optional[int] = Query(None, description="Filtrer par service")
|
|
||||||
):
|
|
||||||
"""Contacts, filtrables par service."""
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
if id_service:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT id_contact, id_service, intitule_contact, nom, prenom, mail "
|
|
||||||
"FROM CONTACT WHERE id_service = ? ORDER BY nom",
|
|
||||||
id_service
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT id_contact, id_service, intitule_contact, nom, prenom, mail "
|
|
||||||
"FROM CONTACT ORDER BY nom"
|
|
||||||
)
|
|
||||||
return rows_to_list(cursor, cursor.fetchall())
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# NOMENCLATURE
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
@router.get("/monitorings", tags=["Monitorings"])
|
|
||||||
def get_monitorings(
|
|
||||||
id_service : Optional[int] = Query(None, description="Filtrer par service"),
|
|
||||||
id_categorie : Optional[int] = Query(None, description="Filtrer par catégorie"),
|
|
||||||
):
|
|
||||||
"""Liste des monitorings actifs avec table_source et bdd_source."""
|
|
||||||
query = (
|
|
||||||
"SELECT id_monito, monito_intitule, id_service, id_categorie, "
|
|
||||||
"table_source, bdd_source "
|
|
||||||
"FROM NOMENCLATURE_MONITO WHERE actif = 1"
|
|
||||||
)
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if id_service:
|
|
||||||
query += " AND id_service = ?"
|
|
||||||
params.append(id_service)
|
|
||||||
if id_categorie:
|
|
||||||
query += " AND id_categorie = ?"
|
|
||||||
params.append(id_categorie)
|
|
||||||
|
|
||||||
query += " ORDER BY id_monito"
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
return rows_to_list(cursor, cursor.fetchall())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitorings/{id_monito}", tags=["Monitorings"])
|
|
||||||
def get_monitoring_by_id(id_monito: int):
|
|
||||||
"""Détail d'un monitoring."""
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT id_monito, monito_intitule, id_service, id_categorie, "
|
|
||||||
"table_source, bdd_source "
|
|
||||||
"FROM NOMENCLATURE_MONITO WHERE id_monito = ? AND actif = 1",
|
|
||||||
id_monito
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Monitoring {id_monito} introuvable.")
|
|
||||||
return row_to_dict(cursor, row)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# DONNÉES DÉTAILLÉES — table dédiée par monitoring
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
@router.get("/monitorings/{id_monito}/details", tags=["Monitorings"])
|
|
||||||
def get_monitoring_details(
|
|
||||||
id_monito : int,
|
|
||||||
search : Optional[str] = Query(
|
|
||||||
None,
|
|
||||||
description="Recherche sur colonnes texte (ct_intitule, agence, STE...)"
|
|
||||||
),
|
|
||||||
limit : int = Query(500, ge=1, le=5000, description="Lignes max — mettre 5000 pour export CSV complet"),
|
|
||||||
offset : int = Query(0, ge=0, description="Offset pagination"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée.
|
|
||||||
Toutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend.
|
|
||||||
"""
|
|
||||||
table = get_table_name(id_monito)
|
|
||||||
query = f"SELECT * FROM {table} WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if search and table in SEARCH_COLS:
|
|
||||||
cols = SEARCH_COLS[table]
|
|
||||||
conditions = " OR ".join([f"{col} LIKE ?" for col in cols])
|
|
||||||
query += f" AND ({conditions})"
|
|
||||||
params.extend([f"%{search}%"] * len(cols))
|
|
||||||
|
|
||||||
query += " ORDER BY date_extraction DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"
|
|
||||||
params += [offset, limit]
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
rows = rows_to_list(cursor, cursor.fetchall())
|
|
||||||
return [serialize_row(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitorings/{id_monito}/count", tags=["Monitorings"])
|
|
||||||
def get_monitoring_count(id_monito: int):
|
|
||||||
"""Nombre d'erreurs dans la table dédiée du monitoring."""
|
|
||||||
table = get_table_name(id_monito)
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
|
||||||
return {
|
|
||||||
"id_monito" : id_monito,
|
|
||||||
"table" : table,
|
|
||||||
"nb_erreurs": cursor.fetchone()[0],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitorings/{id_monito}/columns", tags=["Monitorings"])
|
|
||||||
def get_monitoring_columns(id_monito: int):
|
|
||||||
"""
|
|
||||||
Retourne les colonnes de la table dédiée.
|
|
||||||
Permet au frontend de générer dynamiquement les en-têtes du tableau.
|
|
||||||
"""
|
|
||||||
table = get_table_name(id_monito)
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT COLUMN_NAME, DATA_TYPE "
|
|
||||||
"FROM INFORMATION_SCHEMA.COLUMNS "
|
|
||||||
"WHERE TABLE_NAME = ? ORDER BY ORDINAL_POSITION",
|
|
||||||
table
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"id_monito": id_monito,
|
|
||||||
"table" : table,
|
|
||||||
"columns" : rows_to_list(cursor, cursor.fetchall()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# DASHBOARD — VUE_CONSO
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
@router.get("/dashboard", tags=["Dashboard"])
|
|
||||||
def get_dashboard(
|
|
||||||
service : Optional[str] = Query(None, description="Filtrer par service"),
|
|
||||||
categorie : Optional[str] = Query(None, description="Filtrer par catégorie"),
|
|
||||||
):
|
|
||||||
"""Vue consolidée — source principale du dashboard."""
|
|
||||||
query = (
|
|
||||||
"SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source "
|
|
||||||
"FROM VUE_CONSO WHERE 1=1"
|
|
||||||
)
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if service:
|
|
||||||
query += " AND service = ?"
|
|
||||||
params.append(service)
|
|
||||||
if categorie:
|
|
||||||
query += " AND categorie = ?"
|
|
||||||
params.append(categorie)
|
|
||||||
|
|
||||||
query += " ORDER BY nb_erreurs DESC"
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
return rows_to_list(cursor, cursor.fetchall())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/summary", tags=["Dashboard"])
|
|
||||||
def get_dashboard_summary():
|
|
||||||
"""KPI globaux pour les 4 cartes du dashboard."""
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) AS nb_monitorings,
|
|
||||||
SUM(nb_erreurs) AS total_erreurs,
|
|
||||||
MAX(nb_erreurs) AS max_erreurs,
|
|
||||||
SUM(CASE WHEN nb_erreurs = 0 THEN 1 ELSE 0 END) AS monitorings_ok,
|
|
||||||
SUM(CASE WHEN nb_erreurs > 0 THEN 1 ELSE 0 END) AS monitorings_en_erreur
|
|
||||||
FROM VUE_CONSO
|
|
||||||
""")
|
|
||||||
summary = row_to_dict(cursor, cursor.fetchone())
|
|
||||||
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT TOP 1 id_monito, nom_monito, nb_erreurs, service, bdd_source "
|
|
||||||
"FROM VUE_CONSO ORDER BY nb_erreurs DESC"
|
|
||||||
)
|
|
||||||
top = cursor.fetchone()
|
|
||||||
summary["monitoring_critique"] = row_to_dict(cursor, top) if top else None
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# HISTORIQUE — TABLE_FINAL
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
@router.get("/historique", tags=["Historique"])
|
|
||||||
def get_historique(
|
|
||||||
id_monito : Optional[int] = Query(None),
|
|
||||||
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
|
||||||
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
|
||||||
service : Optional[str] = Query(None),
|
|
||||||
):
|
|
||||||
"""Snapshots journaliers (TABLE_FINAL)."""
|
|
||||||
query = (
|
|
||||||
"SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source, "
|
|
||||||
"CONVERT(NVARCHAR, date_sauvegarde, 23) AS date_sauvegarde "
|
|
||||||
"FROM TABLE_FINAL WHERE 1=1"
|
|
||||||
)
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if id_monito:
|
|
||||||
query += " AND id_monito = ?"
|
|
||||||
params.append(id_monito)
|
|
||||||
if date_debut:
|
|
||||||
query += " AND date_sauvegarde >= ?"
|
|
||||||
params.append(str(date_debut))
|
|
||||||
if date_fin:
|
|
||||||
query += " AND date_sauvegarde <= ?"
|
|
||||||
params.append(str(date_fin))
|
|
||||||
if service:
|
|
||||||
query += " AND service = ?"
|
|
||||||
params.append(service)
|
|
||||||
|
|
||||||
query += " ORDER BY date_sauvegarde ASC, id_monito ASC"
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
return rows_to_list(cursor, cursor.fetchall())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/historique/{id_monito}/evolution", tags=["Historique"])
|
|
||||||
def get_evolution(
|
|
||||||
id_monito : int,
|
|
||||||
date_debut : Optional[date] = Query(None),
|
|
||||||
date_fin : Optional[date] = Query(None),
|
|
||||||
):
|
|
||||||
"""Évolution d'un monitoring — format optimisé Recharts."""
|
|
||||||
get_table_name(id_monito) # valide l'existence du monitoring
|
|
||||||
|
|
||||||
query = (
|
|
||||||
"SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, nb_erreurs "
|
|
||||||
"FROM TABLE_FINAL WHERE id_monito = ?"
|
|
||||||
)
|
|
||||||
params = [id_monito]
|
|
||||||
|
|
||||||
if date_debut:
|
|
||||||
query += " AND date_sauvegarde >= ?"
|
|
||||||
params.append(str(date_debut))
|
|
||||||
if date_fin:
|
|
||||||
query += " AND date_sauvegarde <= ?"
|
|
||||||
params.append(str(date_fin))
|
|
||||||
|
|
||||||
query += " ORDER BY date_sauvegarde ASC"
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()]
|
|
||||||
|
|
||||||
return {"id_monito": id_monito, "points": len(data), "evolution": data}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/historique/comparaison", tags=["Historique"])
|
|
||||||
def get_comparaison(
|
|
||||||
date_debut : Optional[date] = Query(None),
|
|
||||||
date_fin : Optional[date] = Query(None),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Déprécié — utiliser GET /evolution/par-monitoring à la place.
|
|
||||||
Conservé pour compatibilité ascendante.
|
|
||||||
"""
|
|
||||||
query = (
|
|
||||||
"SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, "
|
|
||||||
"id_monito, nom_monito, nb_erreurs, service "
|
|
||||||
"FROM TABLE_FINAL WHERE 1=1"
|
|
||||||
)
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if date_debut:
|
|
||||||
query += " AND date_sauvegarde >= ?"
|
|
||||||
params.append(str(date_debut))
|
|
||||||
if date_fin:
|
|
||||||
query += " AND date_sauvegarde <= ?"
|
|
||||||
params.append(str(date_fin))
|
|
||||||
|
|
||||||
query += " ORDER BY date_sauvegarde ASC, id_monito ASC"
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
return rows_to_list(cursor, cursor.fetchall())
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# ÉVOLUTION GLOBALE — VUE_TABLE_FINAL_CONSO
|
|
||||||
# Vue qui agrège TABLE_FINAL en 3 niveaux :
|
|
||||||
# GLOBAL → total toutes monitorings confondues (courbe principale)
|
|
||||||
# MONITO → détail par monitoring (courbes individuelles)
|
|
||||||
# SERVICE → regroupement par service (Contrat / Fournisseur)
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
@router.get("/evolution/global", tags=["Évolution globale"])
|
|
||||||
def get_evolution_global(
|
|
||||||
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
|
||||||
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Courbe globale : total des erreurs tous monitorings confondus, par jour.
|
|
||||||
Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL'
|
|
||||||
Format optimisé Recharts → [{date, nb_erreurs}]
|
|
||||||
"""
|
|
||||||
query = (
|
|
||||||
"SELECT date_sauvegarde AS date, nb_erreurs "
|
|
||||||
"FROM VUE_TABLE_FINAL_CONSO "
|
|
||||||
"WHERE type_agregat = 'GLOBAL'"
|
|
||||||
)
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if date_debut:
|
|
||||||
query += " AND date_sauvegarde >= ?"
|
|
||||||
params.append(str(date_debut))
|
|
||||||
if date_fin:
|
|
||||||
query += " AND date_sauvegarde <= ?"
|
|
||||||
params.append(str(date_fin))
|
|
||||||
|
|
||||||
query += " ORDER BY date_sauvegarde ASC"
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type" : "GLOBAL",
|
|
||||||
"points" : len(data),
|
|
||||||
"series" : data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/evolution/par-monitoring", tags=["Évolution globale"])
|
|
||||||
def get_evolution_par_monitoring(
|
|
||||||
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
|
||||||
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
|
||||||
id_monito : Optional[int] = Query(None, description="Filtrer sur un seul monitoring"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Évolution par monitoring — toutes les courbes individuelles.
|
|
||||||
Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO'
|
|
||||||
|
|
||||||
Retourne un format pivot par monitoring :
|
|
||||||
{
|
|
||||||
"monitorings": [{"id": 1, "nom": "...", "serie": [{"date","nb_erreurs"}]}]
|
|
||||||
}
|
|
||||||
Utile pour un graphique multi-lignes Recharts (une ligne par monitoring).
|
|
||||||
"""
|
|
||||||
query = (
|
|
||||||
"SELECT date_sauvegarde AS date, id_monito, nom_monito, nb_erreurs "
|
|
||||||
"FROM VUE_TABLE_FINAL_CONSO "
|
|
||||||
"WHERE type_agregat = 'MONITO'"
|
|
||||||
)
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if date_debut:
|
|
||||||
query += " AND date_sauvegarde >= ?"
|
|
||||||
params.append(str(date_debut))
|
|
||||||
if date_fin:
|
|
||||||
query += " AND date_sauvegarde <= ?"
|
|
||||||
params.append(str(date_fin))
|
|
||||||
if id_monito:
|
|
||||||
query += " AND id_monito = ?"
|
|
||||||
params.append(id_monito)
|
|
||||||
|
|
||||||
query += " ORDER BY id_monito ASC, date_sauvegarde ASC"
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
# Pivot : regrouper par monitoring
|
|
||||||
pivot: dict[int, dict] = {}
|
|
||||||
for row in rows:
|
|
||||||
date, mid, nom, nb = row[0], row[1], row[2], row[3]
|
|
||||||
if mid not in pivot:
|
|
||||||
pivot[mid] = {"id_monito": mid, "nom_monito": nom, "serie": []}
|
|
||||||
pivot[mid]["serie"].append({"date": date, "nb_erreurs": nb})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type" : "MONITO",
|
|
||||||
"nb_series" : len(pivot),
|
|
||||||
"monitorings" : list(pivot.values()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/evolution/par-service", tags=["Évolution globale"])
|
|
||||||
def get_evolution_par_service(
|
|
||||||
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
|
||||||
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Évolution par service (Contrat / Fournisseur) — 1 courbe par service.
|
|
||||||
Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE'
|
|
||||||
|
|
||||||
Retourne :
|
|
||||||
{
|
|
||||||
"services": [{"service": "Contrat", "serie": [{"date","nb_erreurs"}]}]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
query = (
|
|
||||||
"SELECT date_sauvegarde AS date, nom_monito AS service, nb_erreurs "
|
|
||||||
"FROM VUE_TABLE_FINAL_CONSO "
|
|
||||||
"WHERE type_agregat = 'SERVICE'"
|
|
||||||
)
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if date_debut:
|
|
||||||
query += " AND date_sauvegarde >= ?"
|
|
||||||
params.append(str(date_debut))
|
|
||||||
if date_fin:
|
|
||||||
query += " AND date_sauvegarde <= ?"
|
|
||||||
params.append(str(date_fin))
|
|
||||||
|
|
||||||
query += " ORDER BY nom_monito ASC, date_sauvegarde ASC"
|
|
||||||
|
|
||||||
with get_cursor() as cursor:
|
|
||||||
cursor.execute(query, *params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
# Pivot par service
|
|
||||||
pivot: dict[str, list] = {}
|
|
||||||
for row in rows:
|
|
||||||
date, service, nb = row[0], row[1], row[2]
|
|
||||||
if service not in pivot:
|
|
||||||
pivot[service] = []
|
|
||||||
pivot[service].append({"date": date, "nb_erreurs": nb})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type" : "SERVICE",
|
|
||||||
"nb_series": len(pivot),
|
|
||||||
"services" : [{"service": s, "serie": v} for s, v in pivot.items()],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# SANTÉ
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@app.get("/health", tags=["Système"])
|
@app.get("/health", tags=["Système"])
|
||||||
def health_check():
|
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:
|
try:
|
||||||
with get_cursor() as cursor:
|
with get_cursor() as cursor:
|
||||||
cursor.execute("SELECT 1")
|
cursor.execute("SELECT 1")
|
||||||
@@ -623,180 +100,23 @@ def health_check():
|
|||||||
"api" : "ok",
|
"api" : "ok",
|
||||||
"database" : db_status,
|
"database" : db_status,
|
||||||
"version" : Config.API_VERSION,
|
"version" : Config.API_VERSION,
|
||||||
|
"build" : Config.BUILD,
|
||||||
"nb_monitorings" : len(MONITO_TABLES),
|
"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):
|
app.include_router(authentification.router)
|
||||||
username: str
|
app.include_router(referentiels.router)
|
||||||
email: str
|
app.include_router(monitorings.router, prefix="/monitorings")
|
||||||
password: str
|
app.include_router(dashboard.router, prefix="/dashboard")
|
||||||
role: str
|
app.include_router(historique.router, prefix="/historique")
|
||||||
|
app.include_router(evolution.router, prefix="/evolution")
|
||||||
class UserUpdate(BaseModel):
|
app.include_router(admin.router)
|
||||||
email: Optional[str] = None
|
app.include_router(rgpd.router)
|
||||||
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)
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# ============================================================
|
||||||
|
# rate_limit.py — Limiteur de débit partagé (anti brute-force)
|
||||||
|
#
|
||||||
|
# Isolé dans son propre module pour que les routeurs puissent
|
||||||
|
# décorer leurs routes sans importer main.py (import circulaire).
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# ============================================================
|
||||||
|
# routers/admin.py — Gestion des comptes et journal d'audit
|
||||||
|
#
|
||||||
|
# Réservé au rôle Admin (403 sinon), via require_admin.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from auth import hash_password, require_admin
|
||||||
|
from config import get_cursor
|
||||||
|
from domain import AuditAction, UserRole
|
||||||
|
from helpers import journaliser, rows_to_list
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Admin"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
role: UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
email: Optional[str] = None
|
||||||
|
role: Optional[UserRole] = None
|
||||||
|
actif: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordReset(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users")
|
||||||
|
def list_users(admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id_user, username, email, role, actif, created_at, last_login "
|
||||||
|
"FROM [USER] ORDER BY id_user"
|
||||||
|
)
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(body: UserCreate, admin: dict = Depends(require_admin)):
|
||||||
|
# Le rôle est validé par Pydantic via l'enum UserRole (422 si invalide).
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO [USER] (username, email, password_hash, role) VALUES (?, ?, ?, ?)",
|
||||||
|
body.username, body.email, hash_password(body.password), body.role.value
|
||||||
|
)
|
||||||
|
journaliser(cursor, admin, AuditAction.CREATE_USER, body.username)
|
||||||
|
return {"status": "created", "username": body.username}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/users/{id_user}")
|
||||||
|
def update_user(id_user: int, body: UserUpdate, admin: dict = Depends(require_admin)):
|
||||||
|
sets, params = [], []
|
||||||
|
if body.email is not None:
|
||||||
|
sets.append("email = ?"); params.append(body.email)
|
||||||
|
if body.role is not None:
|
||||||
|
sets.append("role = ?"); params.append(body.role.value)
|
||||||
|
if body.actif is not None:
|
||||||
|
sets.append("actif = ?"); params.append(1 if body.actif else 0)
|
||||||
|
if not sets:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Aucun champ à modifier"
|
||||||
|
)
|
||||||
|
params.append(id_user)
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(f"UPDATE [USER] SET {', '.join(sets)} WHERE id_user = ?", *params)
|
||||||
|
journaliser(cursor, admin, AuditAction.UPDATE_USER, str(id_user))
|
||||||
|
return {"status": "updated", "id_user": id_user}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/users/{id_user}")
|
||||||
|
def delete_user(id_user: int, admin: dict = Depends(require_admin)):
|
||||||
|
"""Suppression douce (actif = 0)."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute("UPDATE [USER] SET actif = 0 WHERE id_user = ?", id_user)
|
||||||
|
journaliser(cursor, admin, AuditAction.DELETE_USER, str(id_user))
|
||||||
|
return {"status": "deactivated", "id_user": id_user}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users/{id_user}/reset-password")
|
||||||
|
def reset_password(id_user: int, body: PasswordReset, admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute("UPDATE [USER] SET password_hash = ? WHERE id_user = ?",
|
||||||
|
hash_password(body.password), id_user)
|
||||||
|
journaliser(cursor, admin, AuditAction.RESET_PASSWORD, str(id_user))
|
||||||
|
return {"status": "password_reset", "id_user": id_user}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/journal")
|
||||||
|
def get_journal(limit: int = 200, admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT TOP (?) date_action, username, action, detail, ip "
|
||||||
|
"FROM JOURNAL_AUDIT ORDER BY date_action DESC", limit
|
||||||
|
)
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ============================================================
|
||||||
|
# routers/authentification.py — Login JWT et profil courant
|
||||||
|
#
|
||||||
|
# Routes publiques (aucune dépendance d'authentification) :
|
||||||
|
# c'est ici qu'on obtient le jeton exigé par les autres routeurs.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
|
from auth import create_access_token, get_current_user, verify_password
|
||||||
|
from config import get_cursor
|
||||||
|
from domain import AuditAction
|
||||||
|
from rate_limit import limiter
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/login")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
def login(request: Request, form: OAuth2PasswordRequestForm = Depends()):
|
||||||
|
"""Authentifie un utilisateur et retourne un JWT."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id_user, username, password_hash, role, actif "
|
||||||
|
"FROM [USER] WHERE username = ?", form.username
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or not row[4] or not verify_password(form.password, row[2]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Identifiants invalides"
|
||||||
|
)
|
||||||
|
ip = request.client.host if request.client else None
|
||||||
|
cursor.execute("UPDATE [USER] SET last_login = SYSDATETIME() WHERE id_user = ?", row[0])
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO JOURNAL_AUDIT (id_user, username, action, ip) VALUES (?, ?, ?, ?)",
|
||||||
|
row[0], row[1], AuditAction.LOGIN.value, ip
|
||||||
|
)
|
||||||
|
token = create_access_token({"sub": row[1], "role": row[3], "uid": row[0]})
|
||||||
|
return {"access_token": token, "token_type": "bearer",
|
||||||
|
"user": {"username": row[1], "role": row[3]}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/me")
|
||||||
|
def me(user: dict = Depends(get_current_user)):
|
||||||
|
"""Profil de l'utilisateur connecté."""
|
||||||
|
return user
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# ============================================================
|
||||||
|
# routers/dashboard.py — Vue consolidée (VUE_CONSO)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from auth import get_current_user
|
||||||
|
from config import get_cursor
|
||||||
|
from helpers import row_to_dict, rows_to_list
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_dashboard(
|
||||||
|
service : Optional[str] = Query(None, description="Filtrer par service"),
|
||||||
|
categorie : Optional[str] = Query(None, description="Filtrer par catégorie"),
|
||||||
|
):
|
||||||
|
"""Vue consolidée — source principale du dashboard."""
|
||||||
|
query = (
|
||||||
|
"SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source "
|
||||||
|
"FROM VUE_CONSO WHERE 1=1"
|
||||||
|
)
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if service:
|
||||||
|
query += " AND service = ?"
|
||||||
|
params.append(service)
|
||||||
|
if categorie:
|
||||||
|
query += " AND categorie = ?"
|
||||||
|
params.append(categorie)
|
||||||
|
|
||||||
|
query += " ORDER BY nb_erreurs DESC"
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/filtres")
|
||||||
|
def get_dashboard_filtres():
|
||||||
|
"""
|
||||||
|
Valeurs de filtre réellement présentes dans VUE_CONSO.
|
||||||
|
|
||||||
|
Les référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles
|
||||||
|
aucun monitoring actif n'est rattaché (ex. « Business Intelligence »).
|
||||||
|
Les proposer dans les menus déroulants du dashboard mène à un écran vide :
|
||||||
|
cet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring.
|
||||||
|
|
||||||
|
`combinaisons` permet au frontend de restreindre les catégories proposées
|
||||||
|
au service sélectionné.
|
||||||
|
"""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT DISTINCT service, categorie FROM VUE_CONSO "
|
||||||
|
"ORDER BY service, categorie"
|
||||||
|
)
|
||||||
|
combinaisons = [{"service": r[0], "categorie": r[1]} for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"services" : sorted({c["service"] for c in combinaisons}),
|
||||||
|
"categories" : sorted({c["categorie"] for c in combinaisons}),
|
||||||
|
"combinaisons" : combinaisons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def get_dashboard_summary():
|
||||||
|
"""KPI globaux pour les 4 cartes du dashboard."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS nb_monitorings,
|
||||||
|
SUM(nb_erreurs) AS total_erreurs,
|
||||||
|
MAX(nb_erreurs) AS max_erreurs,
|
||||||
|
SUM(CASE WHEN nb_erreurs = 0 THEN 1 ELSE 0 END) AS monitorings_ok,
|
||||||
|
SUM(CASE WHEN nb_erreurs > 0 THEN 1 ELSE 0 END) AS monitorings_en_erreur
|
||||||
|
FROM VUE_CONSO
|
||||||
|
""")
|
||||||
|
summary = row_to_dict(cursor, cursor.fetchone())
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT TOP 1 id_monito, nom_monito, nb_erreurs, service, bdd_source "
|
||||||
|
"FROM VUE_CONSO ORDER BY nb_erreurs DESC"
|
||||||
|
)
|
||||||
|
top = cursor.fetchone()
|
||||||
|
summary["monitoring_critique"] = row_to_dict(cursor, top) if top else None
|
||||||
|
|
||||||
|
return summary
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# ============================================================
|
||||||
|
# routers/evolution.py — Évolution globale (VUE_TABLE_FINAL_CONSO)
|
||||||
|
#
|
||||||
|
# Vue qui agrège TABLE_FINAL en 3 niveaux :
|
||||||
|
# GLOBAL → total toutes monitorings confondues (courbe principale)
|
||||||
|
# MONITO → détail par monitoring (courbes individuelles)
|
||||||
|
# SERVICE → regroupement par service (Contrat / Fournisseur)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from auth import get_current_user
|
||||||
|
from config import get_cursor
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Évolution globale"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/global")
|
||||||
|
def get_evolution_global(
|
||||||
|
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||||
|
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Courbe globale : total des erreurs tous monitorings confondus, par jour.
|
||||||
|
Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL'
|
||||||
|
Format optimisé Recharts → [{date, nb_erreurs}]
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
"SELECT date_sauvegarde AS date, nb_erreurs "
|
||||||
|
"FROM VUE_TABLE_FINAL_CONSO "
|
||||||
|
"WHERE type_agregat = 'GLOBAL'"
|
||||||
|
)
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if date_debut:
|
||||||
|
query += " AND date_sauvegarde >= ?"
|
||||||
|
params.append(str(date_debut))
|
||||||
|
if date_fin:
|
||||||
|
query += " AND date_sauvegarde <= ?"
|
||||||
|
params.append(str(date_fin))
|
||||||
|
|
||||||
|
query += " ORDER BY date_sauvegarde ASC"
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type" : "GLOBAL",
|
||||||
|
"points" : len(data),
|
||||||
|
"series" : data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/par-monitoring")
|
||||||
|
def get_evolution_par_monitoring(
|
||||||
|
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||||
|
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||||
|
id_monito : Optional[int] = Query(None, description="Filtrer sur un seul monitoring"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Évolution par monitoring — toutes les courbes individuelles.
|
||||||
|
Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO'
|
||||||
|
|
||||||
|
Retourne un format pivot par monitoring :
|
||||||
|
{
|
||||||
|
"monitorings": [{"id": 1, "nom": "...", "serie": [{"date","nb_erreurs"}]}]
|
||||||
|
}
|
||||||
|
Utile pour un graphique multi-lignes Recharts (une ligne par monitoring).
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
"SELECT date_sauvegarde AS date, id_monito, nom_monito, nb_erreurs "
|
||||||
|
"FROM VUE_TABLE_FINAL_CONSO "
|
||||||
|
"WHERE type_agregat = 'MONITO'"
|
||||||
|
)
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if date_debut:
|
||||||
|
query += " AND date_sauvegarde >= ?"
|
||||||
|
params.append(str(date_debut))
|
||||||
|
if date_fin:
|
||||||
|
query += " AND date_sauvegarde <= ?"
|
||||||
|
params.append(str(date_fin))
|
||||||
|
if id_monito:
|
||||||
|
query += " AND id_monito = ?"
|
||||||
|
params.append(id_monito)
|
||||||
|
|
||||||
|
query += " ORDER BY id_monito ASC, date_sauvegarde ASC"
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Pivot : regrouper par monitoring
|
||||||
|
pivot: dict[int, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
date_point, mid, nom, nb = row[0], row[1], row[2], row[3]
|
||||||
|
if mid not in pivot:
|
||||||
|
pivot[mid] = {"id_monito": mid, "nom_monito": nom, "serie": []}
|
||||||
|
pivot[mid]["serie"].append({"date": date_point, "nb_erreurs": nb})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type" : "MONITO",
|
||||||
|
"nb_series" : len(pivot),
|
||||||
|
"monitorings" : list(pivot.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/par-service")
|
||||||
|
def get_evolution_par_service(
|
||||||
|
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||||
|
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Évolution par service (Contrat / Fournisseur) — 1 courbe par service.
|
||||||
|
Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE'
|
||||||
|
|
||||||
|
Retourne :
|
||||||
|
{
|
||||||
|
"services": [{"service": "Contrat", "serie": [{"date","nb_erreurs"}]}]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
"SELECT date_sauvegarde AS date, nom_monito AS service, nb_erreurs "
|
||||||
|
"FROM VUE_TABLE_FINAL_CONSO "
|
||||||
|
"WHERE type_agregat = 'SERVICE'"
|
||||||
|
)
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if date_debut:
|
||||||
|
query += " AND date_sauvegarde >= ?"
|
||||||
|
params.append(str(date_debut))
|
||||||
|
if date_fin:
|
||||||
|
query += " AND date_sauvegarde <= ?"
|
||||||
|
params.append(str(date_fin))
|
||||||
|
|
||||||
|
query += " ORDER BY nom_monito ASC, date_sauvegarde ASC"
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Pivot par service
|
||||||
|
pivot: dict[str, list] = {}
|
||||||
|
for row in rows:
|
||||||
|
date_point, service, nb = row[0], row[1], row[2]
|
||||||
|
if service not in pivot:
|
||||||
|
pivot[service] = []
|
||||||
|
pivot[service].append({"date": date_point, "nb_erreurs": nb})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type" : "SERVICE",
|
||||||
|
"nb_series": len(pivot),
|
||||||
|
"services" : [{"service": s, "serie": v} for s, v in pivot.items()],
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# ============================================================
|
||||||
|
# routers/historique.py — Snapshots journaliers (TABLE_FINAL)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from auth import get_current_user
|
||||||
|
from config import get_cursor
|
||||||
|
from helpers import get_table_name, rows_to_list
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Historique"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_historique(
|
||||||
|
id_monito : Optional[int] = Query(None),
|
||||||
|
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||||
|
date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"),
|
||||||
|
service : Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
"""Snapshots journaliers (TABLE_FINAL)."""
|
||||||
|
query = (
|
||||||
|
"SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source, "
|
||||||
|
"CONVERT(NVARCHAR, date_sauvegarde, 23) AS date_sauvegarde "
|
||||||
|
"FROM TABLE_FINAL WHERE 1=1"
|
||||||
|
)
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if id_monito:
|
||||||
|
query += " AND id_monito = ?"
|
||||||
|
params.append(id_monito)
|
||||||
|
if date_debut:
|
||||||
|
query += " AND date_sauvegarde >= ?"
|
||||||
|
params.append(str(date_debut))
|
||||||
|
if date_fin:
|
||||||
|
query += " AND date_sauvegarde <= ?"
|
||||||
|
params.append(str(date_fin))
|
||||||
|
if service:
|
||||||
|
query += " AND service = ?"
|
||||||
|
params.append(service)
|
||||||
|
|
||||||
|
query += " ORDER BY date_sauvegarde ASC, id_monito ASC"
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/comparaison")
|
||||||
|
def get_comparaison(
|
||||||
|
date_debut : Optional[date] = Query(None),
|
||||||
|
date_fin : Optional[date] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Déprécié — utiliser GET /evolution/par-monitoring à la place.
|
||||||
|
Conservé pour compatibilité ascendante.
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
"SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, "
|
||||||
|
"id_monito, nom_monito, nb_erreurs, service "
|
||||||
|
"FROM TABLE_FINAL WHERE 1=1"
|
||||||
|
)
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if date_debut:
|
||||||
|
query += " AND date_sauvegarde >= ?"
|
||||||
|
params.append(str(date_debut))
|
||||||
|
if date_fin:
|
||||||
|
query += " AND date_sauvegarde <= ?"
|
||||||
|
params.append(str(date_fin))
|
||||||
|
|
||||||
|
query += " ORDER BY date_sauvegarde ASC, id_monito ASC"
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id_monito}/evolution")
|
||||||
|
def get_evolution(
|
||||||
|
id_monito : int,
|
||||||
|
date_debut : Optional[date] = Query(None),
|
||||||
|
date_fin : Optional[date] = Query(None),
|
||||||
|
):
|
||||||
|
"""Évolution d'un monitoring — format optimisé Recharts."""
|
||||||
|
get_table_name(id_monito) # valide l'existence du monitoring
|
||||||
|
|
||||||
|
query = (
|
||||||
|
"SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, nb_erreurs "
|
||||||
|
"FROM TABLE_FINAL WHERE id_monito = ?"
|
||||||
|
)
|
||||||
|
params = [id_monito]
|
||||||
|
|
||||||
|
if date_debut:
|
||||||
|
query += " AND date_sauvegarde >= ?"
|
||||||
|
params.append(str(date_debut))
|
||||||
|
if date_fin:
|
||||||
|
query += " AND date_sauvegarde <= ?"
|
||||||
|
params.append(str(date_fin))
|
||||||
|
|
||||||
|
query += " ORDER BY date_sauvegarde ASC"
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
return {"id_monito": id_monito, "points": len(data), "evolution": data}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# ============================================================
|
||||||
|
# routers/monitorings.py — Nomenclature et données détaillées
|
||||||
|
#
|
||||||
|
# Chaque monitoring dispose d'une table MONITO_[NOM] dédiée,
|
||||||
|
# résolue via le mapping MONITO_TABLES (domain.py).
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from auth import get_current_user, require_admin
|
||||||
|
from config import get_cursor
|
||||||
|
from domain import AuditAction, SEARCH_COLS
|
||||||
|
from helpers import get_table_name, journaliser, row_to_dict, rows_to_list, serialize_row
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Monitorings"])
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringBody(BaseModel):
|
||||||
|
monito_intitule: str = Field(min_length=1, max_length=200)
|
||||||
|
id_service: int
|
||||||
|
id_categorie: int
|
||||||
|
table_source: str = Field(min_length=1, max_length=100)
|
||||||
|
bdd_source: str = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringUpdate(BaseModel):
|
||||||
|
monito_intitule: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||||
|
id_service: Optional[int] = None
|
||||||
|
id_categorie: Optional[int] = None
|
||||||
|
table_source: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||||
|
bdd_source: Optional[str] = Field(default=None, min_length=1, max_length=50)
|
||||||
|
actif: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_monitorings(
|
||||||
|
id_service : Optional[int] = Query(None, description="Filtrer par service"),
|
||||||
|
id_categorie : Optional[int] = Query(None, description="Filtrer par catégorie"),
|
||||||
|
):
|
||||||
|
"""Liste des monitorings actifs avec table_source et bdd_source."""
|
||||||
|
query = (
|
||||||
|
"SELECT id_monito, monito_intitule, id_service, id_categorie, "
|
||||||
|
"table_source, bdd_source "
|
||||||
|
"FROM NOMENCLATURE_MONITO WHERE actif = 1"
|
||||||
|
)
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if id_service:
|
||||||
|
query += " AND id_service = ?"
|
||||||
|
params.append(id_service)
|
||||||
|
if id_categorie:
|
||||||
|
query += " AND id_categorie = ?"
|
||||||
|
params.append(id_categorie)
|
||||||
|
|
||||||
|
query += " ORDER BY id_monito"
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_monitoring(body: MonitoringBody, admin: dict = Depends(require_admin)):
|
||||||
|
"""
|
||||||
|
Déclare un monitoring dans la nomenclature.
|
||||||
|
|
||||||
|
Attention : cela n'ajoute que l'entrée de référentiel. Pour qu'il remonte
|
||||||
|
des données, il faut aussi créer la table MONITO_[NOM] correspondante et
|
||||||
|
l'ajouter au mapping MONITO_TABLES — voir l'en-tête de data_sentinel_init.sql.
|
||||||
|
"""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO NOMENCLATURE_MONITO "
|
||||||
|
"(monito_intitule, id_service, id_categorie, table_source, bdd_source, actif) "
|
||||||
|
"OUTPUT INSERTED.id_monito VALUES (?, ?, ?, ?, ?, 1)",
|
||||||
|
body.monito_intitule, body.id_service, body.id_categorie,
|
||||||
|
body.table_source, body.bdd_source
|
||||||
|
)
|
||||||
|
id_monito = cursor.fetchone()[0]
|
||||||
|
journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL,
|
||||||
|
f"NOMENCLATURE_MONITO {id_monito} : {body.monito_intitule}")
|
||||||
|
return {"id_monito": id_monito, **body.model_dump()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{id_monito}")
|
||||||
|
def update_monitoring(id_monito: int, body: MonitoringUpdate, admin: dict = Depends(require_admin)):
|
||||||
|
champs = {
|
||||||
|
"monito_intitule": body.monito_intitule,
|
||||||
|
"id_service": body.id_service,
|
||||||
|
"id_categorie": body.id_categorie,
|
||||||
|
"table_source": body.table_source,
|
||||||
|
"bdd_source": body.bdd_source,
|
||||||
|
"actif": None if body.actif is None else int(body.actif),
|
||||||
|
}
|
||||||
|
modifications = {col: val for col, val in champs.items() if val is not None}
|
||||||
|
if not modifications:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Aucun champ à modifier")
|
||||||
|
|
||||||
|
affectations = ", ".join(f"{col} = ?" for col in modifications)
|
||||||
|
params = [*modifications.values(), id_monito]
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE NOMENCLATURE_MONITO SET {affectations} WHERE id_monito = ?", *params
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Monitoring {id_monito} introuvable.")
|
||||||
|
journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL,
|
||||||
|
f"NOMENCLATURE_MONITO {id_monito}")
|
||||||
|
return {"status": "updated", "id_monito": id_monito}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{id_monito}")
|
||||||
|
def desactiver_monitoring(id_monito: int, admin: dict = Depends(require_admin)):
|
||||||
|
"""
|
||||||
|
Désactivation (actif = 0), pas de suppression physique : TABLE_FINAL
|
||||||
|
référence id_monito et l'historique doit rester consultable.
|
||||||
|
"""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute("UPDATE NOMENCLATURE_MONITO SET actif = 0 WHERE id_monito = ?", id_monito)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Monitoring {id_monito} introuvable.")
|
||||||
|
journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL,
|
||||||
|
f"NOMENCLATURE_MONITO {id_monito} désactivé")
|
||||||
|
return {"status": "deactivated", "id_monito": id_monito}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id_monito}")
|
||||||
|
def get_monitoring_by_id(id_monito: int):
|
||||||
|
"""Détail d'un monitoring."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id_monito, monito_intitule, id_service, id_categorie, "
|
||||||
|
"table_source, bdd_source "
|
||||||
|
"FROM NOMENCLATURE_MONITO WHERE id_monito = ? AND actif = 1",
|
||||||
|
id_monito
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Monitoring {id_monito} introuvable.")
|
||||||
|
return row_to_dict(cursor, row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id_monito}/details")
|
||||||
|
def get_monitoring_details(
|
||||||
|
id_monito : int,
|
||||||
|
search : Optional[str] = Query(
|
||||||
|
None,
|
||||||
|
description="Recherche sur colonnes texte (ct_intitule, agence, STE...)"
|
||||||
|
),
|
||||||
|
limit : int = Query(500, ge=1, le=5000, description="Lignes max — mettre 5000 pour export CSV complet"),
|
||||||
|
offset : int = Query(0, ge=0, description="Offset pagination"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée.
|
||||||
|
Toutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend.
|
||||||
|
"""
|
||||||
|
table = get_table_name(id_monito)
|
||||||
|
query = f"SELECT * FROM {table} WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if search and table in SEARCH_COLS:
|
||||||
|
cols = SEARCH_COLS[table]
|
||||||
|
conditions = " OR ".join([f"{col} LIKE ?" for col in cols])
|
||||||
|
query += f" AND ({conditions})"
|
||||||
|
params.extend([f"%{search}%"] * len(cols))
|
||||||
|
|
||||||
|
query += " ORDER BY date_extraction DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"
|
||||||
|
params += [offset, limit]
|
||||||
|
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(query, *params)
|
||||||
|
rows = rows_to_list(cursor, cursor.fetchall())
|
||||||
|
return [serialize_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id_monito}/count")
|
||||||
|
def get_monitoring_count(id_monito: int):
|
||||||
|
"""Nombre d'erreurs dans la table dédiée du monitoring."""
|
||||||
|
table = get_table_name(id_monito)
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||||
|
return {
|
||||||
|
"id_monito" : id_monito,
|
||||||
|
"table" : table,
|
||||||
|
"nb_erreurs": cursor.fetchone()[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id_monito}/columns")
|
||||||
|
def get_monitoring_columns(id_monito: int):
|
||||||
|
"""
|
||||||
|
Retourne les colonnes de la table dédiée.
|
||||||
|
Permet au frontend de générer dynamiquement les en-têtes du tableau.
|
||||||
|
"""
|
||||||
|
table = get_table_name(id_monito)
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COLUMN_NAME, DATA_TYPE "
|
||||||
|
"FROM INFORMATION_SCHEMA.COLUMNS "
|
||||||
|
"WHERE TABLE_NAME = ? ORDER BY ORDINAL_POSITION",
|
||||||
|
table
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id_monito": id_monito,
|
||||||
|
"table" : table,
|
||||||
|
"columns" : rows_to_list(cursor, cursor.fetchall()),
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# ============================================================
|
||||||
|
# routers/referentiels.py — Services, catégories et contacts
|
||||||
|
#
|
||||||
|
# Lecture ouverte à tout utilisateur authentifié ; création,
|
||||||
|
# modification et suppression réservées aux administrateurs.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from auth import get_current_user, require_admin
|
||||||
|
from config import get_cursor
|
||||||
|
from domain import AuditAction
|
||||||
|
from helpers import journaliser, row_to_dict, rows_to_list
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(get_current_user)], tags=["Référentiels"])
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceBody(BaseModel):
|
||||||
|
nom_service: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class CategorieBody(BaseModel):
|
||||||
|
intitule_categorie: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactBody(BaseModel):
|
||||||
|
id_service: int
|
||||||
|
intitule_contact: str = Field(min_length=1, max_length=100)
|
||||||
|
nom: str = Field(min_length=1, max_length=100)
|
||||||
|
prenom: str = Field(min_length=1, max_length=100)
|
||||||
|
mail: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Catégories
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
def get_categories():
|
||||||
|
"""Toutes les catégories de monitoring."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id_categorie, intitule_categorie "
|
||||||
|
"FROM CATEGORIE ORDER BY intitule_categorie"
|
||||||
|
)
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_categorie(body: CategorieBody, admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO CATEGORIE (intitule_categorie) OUTPUT INSERTED.id_categorie VALUES (?)",
|
||||||
|
body.intitule_categorie
|
||||||
|
)
|
||||||
|
id_categorie = cursor.fetchone()[0]
|
||||||
|
journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL,
|
||||||
|
f"CATEGORIE {id_categorie} : {body.intitule_categorie}")
|
||||||
|
return {"id_categorie": id_categorie, "intitule_categorie": body.intitule_categorie}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/categories/{id_categorie}")
|
||||||
|
def update_categorie(id_categorie: int, body: CategorieBody, admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE CATEGORIE SET intitule_categorie = ? WHERE id_categorie = ?",
|
||||||
|
body.intitule_categorie, id_categorie
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Catégorie {id_categorie} introuvable.")
|
||||||
|
journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL,
|
||||||
|
f"CATEGORIE {id_categorie} : {body.intitule_categorie}")
|
||||||
|
return {"id_categorie": id_categorie, "intitule_categorie": body.intitule_categorie}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/categories/{id_categorie}")
|
||||||
|
def delete_categorie(id_categorie: int, admin: dict = Depends(require_admin)):
|
||||||
|
"""
|
||||||
|
Supprime une catégorie, sauf si des monitorings s'y rattachent encore :
|
||||||
|
la clé étrangère FK_NOM_CATEGORIE l'interdirait, autant renvoyer un
|
||||||
|
message explicite plutôt que de laisser remonter l'erreur SQL brute.
|
||||||
|
"""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) FROM NOMENCLATURE_MONITO WHERE id_categorie = ?", id_categorie
|
||||||
|
)
|
||||||
|
nb_rattaches = cursor.fetchone()[0]
|
||||||
|
if nb_rattaches:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Suppression impossible : {nb_rattaches} monitoring(s) utilisent cette catégorie."
|
||||||
|
)
|
||||||
|
cursor.execute("DELETE FROM CATEGORIE WHERE id_categorie = ?", id_categorie)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Catégorie {id_categorie} introuvable.")
|
||||||
|
journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"CATEGORIE {id_categorie}")
|
||||||
|
return {"status": "deleted", "id_categorie": id_categorie}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Services
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/services")
|
||||||
|
def get_services():
|
||||||
|
"""Tous les services."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute("SELECT id_service, nom_service FROM SERVICE ORDER BY nom_service")
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/services", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_service(body: ServiceBody, admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO SERVICE (nom_service) OUTPUT INSERTED.id_service VALUES (?)",
|
||||||
|
body.nom_service
|
||||||
|
)
|
||||||
|
id_service = cursor.fetchone()[0]
|
||||||
|
journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL,
|
||||||
|
f"SERVICE {id_service} : {body.nom_service}")
|
||||||
|
return {"id_service": id_service, "nom_service": body.nom_service}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/services/{id_service}")
|
||||||
|
def update_service(id_service: int, body: ServiceBody, admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE SERVICE SET nom_service = ? WHERE id_service = ?",
|
||||||
|
body.nom_service, id_service
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Service {id_service} introuvable.")
|
||||||
|
journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL,
|
||||||
|
f"SERVICE {id_service} : {body.nom_service}")
|
||||||
|
return {"id_service": id_service, "nom_service": body.nom_service}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/services/{id_service}")
|
||||||
|
def delete_service(id_service: int, admin: dict = Depends(require_admin)):
|
||||||
|
"""Refuse la suppression tant que des monitorings ou contacts y sont rattachés."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT "
|
||||||
|
"(SELECT COUNT(*) FROM NOMENCLATURE_MONITO WHERE id_service = ?), "
|
||||||
|
"(SELECT COUNT(*) FROM CONTACT WHERE id_service = ?)",
|
||||||
|
id_service, id_service
|
||||||
|
)
|
||||||
|
nb_monitorings, nb_contacts = cursor.fetchone()
|
||||||
|
if nb_monitorings or nb_contacts:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(f"Suppression impossible : {nb_monitorings} monitoring(s) "
|
||||||
|
f"et {nb_contacts} contact(s) rattachés à ce service.")
|
||||||
|
)
|
||||||
|
cursor.execute("DELETE FROM SERVICE WHERE id_service = ?", id_service)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Service {id_service} introuvable.")
|
||||||
|
journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"SERVICE {id_service}")
|
||||||
|
return {"status": "deleted", "id_service": id_service}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Contacts
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/contacts")
|
||||||
|
def get_contacts(
|
||||||
|
id_service: Optional[int] = Query(None, description="Filtrer par service")
|
||||||
|
):
|
||||||
|
"""Contacts, filtrables par service."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
if id_service:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id_contact, id_service, intitule_contact, nom, prenom, mail "
|
||||||
|
"FROM CONTACT WHERE id_service = ? ORDER BY nom",
|
||||||
|
id_service
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id_contact, id_service, intitule_contact, nom, prenom, mail "
|
||||||
|
"FROM CONTACT ORDER BY nom"
|
||||||
|
)
|
||||||
|
return rows_to_list(cursor, cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contacts", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_contact(body: ContactBody, admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO CONTACT (id_service, intitule_contact, nom, prenom, mail) "
|
||||||
|
"OUTPUT INSERTED.id_contact VALUES (?, ?, ?, ?, ?)",
|
||||||
|
body.id_service, body.intitule_contact, body.nom, body.prenom, body.mail
|
||||||
|
)
|
||||||
|
id_contact = cursor.fetchone()[0]
|
||||||
|
journaliser(cursor, admin, AuditAction.CREATE_REFERENTIEL,
|
||||||
|
f"CONTACT {id_contact} : {body.nom} {body.prenom}")
|
||||||
|
return {"id_contact": id_contact, **body.model_dump()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/contacts/{id_contact}")
|
||||||
|
def update_contact(id_contact: int, body: ContactBody, admin: dict = Depends(require_admin)):
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE CONTACT SET id_service = ?, intitule_contact = ?, nom = ?, prenom = ?, mail = ? "
|
||||||
|
"WHERE id_contact = ?",
|
||||||
|
body.id_service, body.intitule_contact, body.nom, body.prenom, body.mail, id_contact
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Contact {id_contact} introuvable.")
|
||||||
|
journaliser(cursor, admin, AuditAction.UPDATE_REFERENTIEL,
|
||||||
|
f"CONTACT {id_contact} : {body.nom} {body.prenom}")
|
||||||
|
return {"id_contact": id_contact, **body.model_dump()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/contacts/{id_contact}")
|
||||||
|
def delete_contact(id_contact: int, admin: dict = Depends(require_admin)):
|
||||||
|
"""Un contact n'est référencé par aucune autre table : suppression directe."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute("DELETE FROM CONTACT WHERE id_contact = ?", id_contact)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Contact {id_contact} introuvable.")
|
||||||
|
journaliser(cursor, admin, AuditAction.DELETE_REFERENTIEL, f"CONTACT {id_contact}")
|
||||||
|
return {"status": "deleted", "id_contact": id_contact}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# ============================================================
|
||||||
|
# routers/rgpd.py — Droits de la personne concernée (RGPD)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from auth import get_current_user
|
||||||
|
from config import get_cursor
|
||||||
|
from helpers import row_to_dict, rows_to_list
|
||||||
|
|
||||||
|
router = APIRouter(tags=["RGPD"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/data-export")
|
||||||
|
def export_my_data(user: dict = Depends(get_current_user)):
|
||||||
|
"""Droit à la portabilité (art. 20) : export des données de l'utilisateur."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id_user, username, email, role, actif, created_at, last_login "
|
||||||
|
"FROM [USER] WHERE username = ?", user["username"]
|
||||||
|
)
|
||||||
|
u = cursor.fetchone()
|
||||||
|
profile = row_to_dict(cursor, u) if u else {}
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT date_action, action, detail, ip FROM JOURNAL_AUDIT "
|
||||||
|
"WHERE username = ? ORDER BY date_action DESC", user["username"]
|
||||||
|
)
|
||||||
|
journal = rows_to_list(cursor, cursor.fetchall())
|
||||||
|
return {"profile": profile, "journal": journal}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me")
|
||||||
|
def delete_my_account(user: dict = Depends(get_current_user)):
|
||||||
|
"""Droit à l'oubli (art. 17) : désactivation + anonymisation."""
|
||||||
|
with get_cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE [USER] SET actif = 0, "
|
||||||
|
"username = CONCAT('deleted_', id_user), "
|
||||||
|
"email = CONCAT('deleted_', id_user, '@deleted.local') "
|
||||||
|
"WHERE username = ?", user["username"]
|
||||||
|
)
|
||||||
|
cursor.execute("UPDATE JOURNAL_AUDIT SET username = NULL WHERE username = ?", user["username"])
|
||||||
|
return {"status": "account_deleted"}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- DATA SENTINEL — Création / réinitialisation du compte « Juré »
|
||||||
|
-- Auteur : COYAUD Anthony
|
||||||
|
-- Version : 1.1 — Août 2026
|
||||||
|
--
|
||||||
|
-- Compte Admin destiné aux personnes qui consultent l'application
|
||||||
|
-- (évaluateurs, jury, démonstration).
|
||||||
|
--
|
||||||
|
-- identifiant : Juré
|
||||||
|
-- mail : JURE@NEXA.com
|
||||||
|
-- mot de passe : 123456
|
||||||
|
-- rôle : Admin
|
||||||
|
--
|
||||||
|
-- Script idempotent : il peut être rejoué sur une base déjà déployée
|
||||||
|
-- sans dupliquer le compte ni toucher aux autres utilisateurs.
|
||||||
|
-- Prérequis : data_sentinel_auth.sql déjà exécuté ([USER] + JOURNAL_AUDIT).
|
||||||
|
--
|
||||||
|
-- Pas d'instruction USE : la base cible vient du paramètre -d de la
|
||||||
|
-- connexion, et le garde-fou ci-dessous interrompt le script si ce
|
||||||
|
-- n'est pas la bonne. Tout tient dans un seul lot (aucun GO), sans
|
||||||
|
-- quoi le RETURN du garde-fou n'empêcherait pas la suite de tourner.
|
||||||
|
--
|
||||||
|
-- Exécution :
|
||||||
|
-- sqlcmd -S <serveur> -d <base> -U <user> -P <mdp> -C -b -f 65001 \
|
||||||
|
-- -i sql/create_admin_jure.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
DECLARE @base NVARCHAR(128) = DB_NAME();
|
||||||
|
|
||||||
|
IF OBJECT_ID('[USER]', 'U') IS NULL OR OBJECT_ID('JOURNAL_AUDIT', 'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
RAISERROR(
|
||||||
|
'ARRET : base incorrecte (%s). [USER] ou JOURNAL_AUDIT est introuvable. Reconnectez-vous sur la base Data Sentinel (option -d).',
|
||||||
|
16, 1, @base);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Hachage bcrypt (coût 12) du mot de passe de démonstration.
|
||||||
|
-- Généré avec passlib.CryptContext(schemes=["bcrypt"]) — même librairie que auth.py.
|
||||||
|
DECLARE @username NVARCHAR(100) = N'Juré';
|
||||||
|
DECLARE @email NVARCHAR(200) = N'JURE@NEXA.com';
|
||||||
|
DECLARE @password_hash NVARCHAR(255) = N'$2b$12$nnWMOCjSiUq4rAyPGbC3W.HGdsvWKu9kEfLst6zBIEIcsgP97W94m';
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM [USER] WHERE username = @username)
|
||||||
|
BEGIN
|
||||||
|
UPDATE [USER]
|
||||||
|
SET email = @email,
|
||||||
|
password_hash = @password_hash,
|
||||||
|
role = N'Admin',
|
||||||
|
actif = 1
|
||||||
|
WHERE username = @username;
|
||||||
|
|
||||||
|
INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail)
|
||||||
|
SELECT id_user, username, 'RESET_PASSWORD', 'Compte de visite reinitialise (script SQL)'
|
||||||
|
FROM [USER] WHERE username = @username;
|
||||||
|
|
||||||
|
PRINT 'Compte "Jure" deja present -> mis a jour.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO [USER] (username, email, password_hash, role, actif)
|
||||||
|
VALUES (@username, @email, @password_hash, N'Admin', 1);
|
||||||
|
|
||||||
|
INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail)
|
||||||
|
SELECT id_user, username, 'CREATE_USER', 'Compte de visite cree (script SQL)'
|
||||||
|
FROM [USER] WHERE username = @username;
|
||||||
|
|
||||||
|
PRINT 'Compte "Jure" cree.';
|
||||||
|
END
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
|
||||||
|
PRINT 'ECHEC : ' + ERROR_MESSAGE();
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
|
||||||
|
-- Vérification
|
||||||
|
SELECT id_user, username, email, role, actif, created_at, last_login
|
||||||
|
FROM [USER]
|
||||||
|
WHERE username = N'Juré';
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- DATA SENTINEL — Script d'authentification SQL Server
|
||||||
|
-- Tables [USER] + JOURNAL_AUDIT + comptes de démonstration
|
||||||
|
-- Auteur : COYAUD Anthony
|
||||||
|
-- Version : 1.1 — Juin 2026
|
||||||
|
-- À exécuter APRÈS data_sentinel_init.sql (même base DataSentinel)
|
||||||
|
-- ============================================================
|
||||||
|
--
|
||||||
|
-- Ce script crée la couche de sécurité référencée par l'API
|
||||||
|
-- (main.py / auth.py) :
|
||||||
|
-- - [USER] : comptes applicatifs (RBAC : Admin / Superviseur / Consultant)
|
||||||
|
-- - JOURNAL_AUDIT : journal d'audit (connexions + actions d'administration)
|
||||||
|
--
|
||||||
|
-- Les mots de passe sont stockés hachés en bcrypt (passlib[bcrypt]).
|
||||||
|
-- Les hachages ci-dessous correspondent aux comptes de démonstration
|
||||||
|
-- documentés dans le README (à régénérer en production).
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
USE DataSentinel;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SUPPRESSION (ordre inverse des dépendances)
|
||||||
|
-- ============================================================
|
||||||
|
IF OBJECT_ID('JOURNAL_AUDIT', 'U') IS NOT NULL DROP TABLE JOURNAL_AUDIT;
|
||||||
|
IF OBJECT_ID('[USER]', 'U') IS NOT NULL DROP TABLE [USER];
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. TABLE [USER] — comptes applicatifs
|
||||||
|
-- ([USER] entre crochets car USER est un mot réservé SQL Server)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE [USER] (
|
||||||
|
id_user INT NOT NULL IDENTITY(1,1),
|
||||||
|
username NVARCHAR(100) NOT NULL,
|
||||||
|
email NVARCHAR(200) NULL,
|
||||||
|
password_hash NVARCHAR(255) NOT NULL, -- bcrypt
|
||||||
|
role NVARCHAR(20) NOT NULL, -- Admin | Superviseur | Consultant
|
||||||
|
actif BIT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME2 NOT NULL DEFAULT SYSDATETIME(),
|
||||||
|
last_login DATETIME2 NULL,
|
||||||
|
CONSTRAINT PK_USER PRIMARY KEY (id_user),
|
||||||
|
CONSTRAINT UQ_USER_NAME UNIQUE (username),
|
||||||
|
CONSTRAINT CK_USER_ROLE CHECK (role IN ('Admin', 'Superviseur', 'Consultant'))
|
||||||
|
);
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE INDEX IX_USER_USERNAME ON [USER] (username);
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. TABLE JOURNAL_AUDIT — journal d'audit applicatif
|
||||||
|
-- Alimenté à chaque login + action d'administration.
|
||||||
|
-- username/id_user nullables (anonymisation RGPD : droit à l'oubli).
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE JOURNAL_AUDIT (
|
||||||
|
id_audit INT NOT NULL IDENTITY(1,1),
|
||||||
|
id_user INT NULL,
|
||||||
|
username NVARCHAR(100) NULL,
|
||||||
|
action NVARCHAR(50) NOT NULL, -- LOGIN | CREATE_USER | UPDATE_USER | DELETE_USER | RESET_PASSWORD
|
||||||
|
detail NVARCHAR(500) NULL,
|
||||||
|
ip NVARCHAR(50) NULL,
|
||||||
|
date_action DATETIME2 NOT NULL DEFAULT SYSDATETIME(),
|
||||||
|
CONSTRAINT PK_JOURNAL_AUDIT PRIMARY KEY (id_audit)
|
||||||
|
);
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE INDEX IX_AUDIT_DATE ON JOURNAL_AUDIT (date_action DESC);
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. COMPTES DE DÉMONSTRATION
|
||||||
|
-- ⚠️ Mots de passe en clair (à usage de recette / soutenance) :
|
||||||
|
-- Juré / 123456 → rôle Admin (compte de visite / évaluation)
|
||||||
|
-- admin / Admin2026! → rôle Admin
|
||||||
|
-- superviseur / Super2026! → rôle Superviseur
|
||||||
|
-- consultant / Conseil2026! → rôle Consultant
|
||||||
|
-- Les hachages bcrypt ci-dessous sont fonctionnels tels quels.
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO [USER] (username, email, password_hash, role, actif) VALUES
|
||||||
|
(N'Juré', 'JURE@NEXA.com',
|
||||||
|
'$2b$12$nnWMOCjSiUq4rAyPGbC3W.HGdsvWKu9kEfLst6zBIEIcsgP97W94m', 'Admin', 1),
|
||||||
|
('admin', 'admin@xefi-fictif.fr',
|
||||||
|
'$2b$12$XJobnqM0cHwekv7UYWXUfuxVjMKLZI6VLzpDvRzDdLJgnYHLRNRCy', 'Admin', 1),
|
||||||
|
('superviseur', 'superviseur@xefi-fictif.fr',
|
||||||
|
'$2b$12$gMVg2j6wngZZ2KXH2jF6HuvlcAkGIJsbZ05JEQdDYNnIu7fDxOXri', 'Superviseur', 1),
|
||||||
|
('consultant', 'consultant@xefi-fictif.fr',
|
||||||
|
'$2b$12$s4Gl3UDq56HOw/GdjeHCrOFjXN/QrV1OEj3wmHhf3T8mFVfijJkty', 'Consultant', 1);
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Entrée d'audit initiale (création du jeu de comptes)
|
||||||
|
INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail)
|
||||||
|
SELECT id_user, username, 'CREATE_USER', 'Compte de démonstration (seed)'
|
||||||
|
FROM [USER];
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. VÉRIFICATIONS
|
||||||
|
-- ============================================================
|
||||||
|
SELECT 'USER' AS [Table], COUNT(*) AS [Lignes] FROM [USER]
|
||||||
|
UNION ALL SELECT 'JOURNAL_AUDIT', COUNT(*) FROM JOURNAL_AUDIT;
|
||||||
|
GO
|
||||||
|
|
||||||
|
SELECT id_user, username, email, role, actif, created_at FROM [USER] ORDER BY id_user;
|
||||||
|
GO
|
||||||
-47
@@ -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
@@ -9,6 +9,24 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
import main
|
import main
|
||||||
from auth import create_access_token
|
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:
|
class FakeCursor:
|
||||||
@@ -17,6 +35,7 @@ class FakeCursor:
|
|||||||
self.description = []
|
self.description = []
|
||||||
self._rows = []
|
self._rows = []
|
||||||
self._one = None
|
self._one = None
|
||||||
|
self.rowcount = 1
|
||||||
self.executed = []
|
self.executed = []
|
||||||
|
|
||||||
def execute(self, query, *args):
|
def execute(self, query, *args):
|
||||||
@@ -38,10 +57,27 @@ def cur(monkeypatch):
|
|||||||
def fake_get_cursor():
|
def fake_get_cursor():
|
||||||
yield c
|
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
|
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
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
return TestClient(main.app)
|
return TestClient(main.app)
|
||||||
|
|||||||
@@ -22,6 +22,24 @@ def test_services_returns_list(client, cur, auth_headers):
|
|||||||
assert {"id_service": 1, "nom_service": "Contrat"} in r.json()
|
assert {"id_service": 1, "nom_service": "Contrat"} in r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_filtres_ne_renvoie_que_les_valeurs_utilisees(client, cur, auth_headers):
|
||||||
|
# VUE_CONSO ne contient que Contrat / Fournisseur : les services du
|
||||||
|
# référentiel sans monitoring rattaché ne doivent pas remonter.
|
||||||
|
cur.description = [("service",), ("categorie",)]
|
||||||
|
cur._rows = [
|
||||||
|
("Contrat", "DOM-TOM"),
|
||||||
|
("Contrat", "Tiers-payeurs"),
|
||||||
|
("Fournisseur", "Contreparties"),
|
||||||
|
]
|
||||||
|
r = client.get("/dashboard/filtres", headers=auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["services"] == ["Contrat", "Fournisseur"]
|
||||||
|
assert "Business Intelligence" not in body["services"]
|
||||||
|
assert body["categories"] == ["Contreparties", "DOM-TOM", "Tiers-payeurs"]
|
||||||
|
assert {"service": "Contrat", "categorie": "DOM-TOM"} in body["combinaisons"]
|
||||||
|
|
||||||
|
|
||||||
def test_auth_login_success(client, cur):
|
def test_auth_login_success(client, cur):
|
||||||
cur._one = (1, "admin", hash_password("Admin2026!"), "Admin", 1)
|
cur._one = (1, "admin", hash_password("Admin2026!"), "Admin", 1)
|
||||||
r = client.post("/auth/login", data={"username": "admin", "password": "Admin2026!"})
|
r = client.post("/auth/login", data={"username": "admin", "password": "Admin2026!"})
|
||||||
@@ -48,6 +66,70 @@ def test_monitoring_unknown_returns_404(client, cur, auth_headers):
|
|||||||
assert r.status_code == 404
|
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):
|
def test_admin_forbidden_for_consultant(client):
|
||||||
token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2})
|
token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2})
|
||||||
r = client.get("/admin/users", headers={"Authorization": f"Bearer {token}"})
|
r = client.get("/admin/users", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user