feat(api): CRUD des référentiels réservé aux administrateurs

L'écran d'administration du front proposait des boutons Modifier et
Désactiver qui n'appelaient rien (console.log côté client), faute
d'endpoints correspondants. Ajoute POST/PUT/DELETE sur /services,
/categories, /contacts et /monitorings, tous protégés par require_admin.

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

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

Tests : 8 -> 16. Couvre la validation par enum, le refus 409 sur
rattachement, le 403 pour un non-administrateur et la désactivation
logique du monitoring.
This commit is contained in:
2026-08-15 14:28:58 +02:00
parent 934e669774
commit e7b3944436
3 changed files with 96 additions and 6 deletions
+31 -5
View File
@@ -21,15 +21,17 @@ python -m uvicorn main:app --reload --port 8000
- Swagger : `http://127.0.0.1:8000/docs` · Santé : `http://127.0.0.1:8000/health`
Sans variables d'environnement, l'API se connecte en authentification Windows
(`LaptopCA\SQLEXPRESS`) — comportement de dev d'origine, inchangé.
sur `localhost`. Pour une instance nommée, définir `DB_SERVER` (ex.
`DB_SERVER=MonPoste\SQLEXPRESS`, sans `DB_PORT`).
## Configuration (variables d'environnement)
| Variable | Rôle | Défaut |
|----------|------|--------|
| `DB_SERVER` | hôte SQL Server (active l'auth SQL si défini) | — (sinon Windows local) |
| `DB_PORT` / `DB_NAME` | port / base | `1433` / `DataSentinel` |
| `DB_USER` / `DB_PASSWORD` | compte applicatif | — |
| `DB_SERVER` | hôte SQL Server (ou `hôte\instance`) | `localhost` |
| `DB_PORT` / `DB_NAME` | port / base — laisser `DB_PORT` vide pour une instance nommée | — / `DataSentinel` |
| `DB_USER` / `DB_PASSWORD` | compte applicatif ; sa présence active l'authentification SQL | — (sinon auth Windows) |
| `DB_TRUSTED_CONNECTION` | force l'authentification Windows même si `DB_USER` est défini | — |
| `DB_DRIVER` | pilote ODBC | `ODBC Driver 18 for SQL Server` |
| `CORS_ORIGINS` | origines autorisées (séparées par `,`) | `localhost:5173,localhost:3000` |
| `JWT_SECRET` | clé de signature JWT | placeholder (à définir en prod) |
@@ -62,10 +64,32 @@ Les mots de passe ci-dessus sont des identifiants de démonstration : ils sont
stockés hachés (bcrypt, coût 12) et doivent être régénérés avant toute mise en
production réelle (`POST /admin/users/{id}/reset-password`).
## Organisation du code
`main.py` ne fait que l'assemblage (configuration, middlewares, montage des
routeurs). Chaque domaine fonctionnel vit dans `routers/` :
| Module | Responsabilité |
|--------|----------------|
| `routers/authentification.py` | `/auth/login` (rate-limité), `/auth/me` |
| `routers/referentiels.py` | services, catégories, contacts — lecture + CRUD Admin |
| `routers/monitorings.py` | nomenclature + données détaillées par table dédiée |
| `routers/dashboard.py` | `VUE_CONSO` : vue consolidée, filtres, KPI |
| `routers/historique.py` | snapshots journaliers (`TABLE_FINAL`) |
| `routers/evolution.py` | `VUE_TABLE_FINAL_CONSO` : courbes global / monitoring / service |
| `routers/admin.py` | comptes utilisateurs et journal d'audit |
| `routers/rgpd.py` | portabilité et droit à l'oubli |
| `domain.py` | enums (`UserRole`, `AuditAction`) et mapping `MONITO_TABLES` |
| `helpers.py` | conversion des lignes pyodbc, écriture du journal d'audit |
## Endpoints (résumé)
- **Données** (protégés) : `/categories`, `/services`, `/contacts`, `/monitorings[...]`,
`/dashboard[...]`, `/historique[...]`, `/evolution/*`.
- **Référentiels** (`Admin` en écriture) : `POST/PUT/DELETE` sur `/services`,
`/categories`, `/contacts` et `/monitorings`. La suppression est refusée (409)
tant que des enregistrements y sont rattachés ; un monitoring est **désactivé**
(`actif = 0`) et jamais supprimé, car `TABLE_FINAL` référence son identifiant.
- **Admin** (`Admin`) : `GET/POST /admin/users`, `PUT/DELETE /admin/users/{id}`,
`POST /admin/users/{id}/reset-password`, `GET /admin/journal`.
- **RGPD** : `GET /me/data-export` (portabilité), `DELETE /me` (droit à l'oubli + anonymisation).
@@ -86,7 +110,9 @@ Spécification complète : `GET /openapi.json` (export dans `docs/openapi.json`)
```bash
pip install pytest httpx && pytest -q
```
Curseur SQL simulé (aucune vraie BDD) : santé, auth (succès/échec), protection 401, RBAC 403, 404.
Curseur SQL simulé (aucune vraie BDD) : santé, auth (succès/échec), protection 401,
RBAC 403, 404, validation des rôles par enum (422), refus de suppression d'un
référentiel rattaché (409), désactivation logique d'un monitoring.
## Docker & CI/CD
+1 -1
View File
File diff suppressed because one or more lines are too long
+64
View File
@@ -66,6 +66,70 @@ def test_monitoring_unknown_returns_404(client, cur, auth_headers):
assert r.status_code == 404
def test_create_user_role_invalide_rejete(client, cur, auth_headers):
# Le rôle est contraint par l'enum UserRole : Pydantic rejette en 422
# avant d'atteindre la base, sans validation manuelle dans la route.
r = client.post(
"/admin/users",
headers=auth_headers,
json={"username": "x", "email": "x@x.fr", "password": "p", "role": "Root"},
)
assert r.status_code == 422
def test_create_user_role_valide_accepte(client, cur, auth_headers):
r = client.post(
"/admin/users",
headers=auth_headers,
json={"username": "x", "email": "x@x.fr", "password": "p", "role": "Superviseur"},
)
assert r.status_code == 201
def test_creation_service_reservee_admin(client, cur):
# Un Consultant ne doit pas pouvoir écrire dans le référentiel.
token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2})
r = client.post(
"/services",
headers={"Authorization": f"Bearer {token}"},
json={"nom_service": "Nouveau"},
)
assert r.status_code == 403
def test_creation_service_par_admin(client, cur, auth_headers):
cur._one = (42,)
r = client.post("/services", headers=auth_headers, json={"nom_service": "Comptabilité"})
assert r.status_code == 201
assert r.json() == {"id_service": 42, "nom_service": "Comptabilité"}
def test_suppression_service_refusee_si_rattachements(client, cur, auth_headers):
# 2 monitorings et 1 contact rattachés : la suppression doit être bloquée
# explicitement plutôt que de laisser remonter une violation de clé étrangère.
cur._one = (2, 1)
r = client.delete("/services/1", headers=auth_headers)
assert r.status_code == 409
assert "2 monitoring(s)" in r.json()["detail"]
def test_suppression_service_sans_rattachement(client, cur, auth_headers):
cur._one = (0, 0)
r = client.delete("/services/1", headers=auth_headers)
assert r.status_code == 200
assert r.json()["status"] == "deleted"
def test_desactivation_monitoring_ne_supprime_pas(client, cur, auth_headers):
# TABLE_FINAL référence id_monito : on désactive, on ne supprime jamais.
r = client.delete("/monitorings/1", headers=auth_headers)
assert r.status_code == 200
assert r.json()["status"] == "deactivated"
requetes = " ".join(q for q, _ in cur.executed)
assert "UPDATE NOMENCLATURE_MONITO SET actif = 0" in requetes
assert "DELETE FROM NOMENCLATURE_MONITO" not in requetes
def test_admin_forbidden_for_consultant(client):
token = create_access_token({"sub": "bob", "role": "Consultant", "uid": 2})
r = client.get("/admin/users", headers={"Authorization": f"Bearer {token}"})