diff --git a/README.md b/README.md index 3f2710b..8b8d1b7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.py b/config.py index 4b67bc7..b05fc59 100644 --- a/config.py +++ b/config.py @@ -10,27 +10,38 @@ from contextlib import contextmanager def _build_connection_string() -> str: """ - En déploiement (variables d'environnement présentes), on se connecte en - authentification SQL. Sinon, on garde la connexion Windows locale par défaut - (poste de dev). + Construit la chaîne de connexion ODBC à partir des variables + d'environnement. + + Deux modes d'authentification : + • authentification SQL (déploiement) — dès que DB_USER est défini ; + • authentification Windows (poste de dev) — sinon, ou en forçant + DB_TRUSTED_CONNECTION=yes. + + Le serveur par défaut reste modifiable par DB_SERVER : coder en dur le + nom d'un poste rendait l'API inutilisable sur toute autre machine. """ - server = os.getenv("DB_SERVER") - if server: - driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server") - port = os.getenv("DB_PORT", "1433") - return ( - f"Driver={{{driver}}};" - f"Server={server},{port};" - f"Database={os.getenv('DB_NAME', 'DataSentinel')};" - f"UID={os.getenv('DB_USER')};" - f"PWD={os.getenv('DB_PASSWORD')};" - "Encrypt=yes;TrustServerCertificate=yes;" - ) + server = os.getenv("DB_SERVER", "localhost") + port = os.getenv("DB_PORT", "") + base = os.getenv("DB_NAME", "DataSentinel") + driver = os.getenv("DB_DRIVER", "ODBC Driver 18 for SQL Server") + user = os.getenv("DB_USER") + + # Une instance nommée (MonPoste\SQLEXPRESS) se joint sans port explicite. + adresse = f"{server},{port}" if port else server + + trusted = os.getenv("DB_TRUSTED_CONNECTION", "").lower() in ("1", "yes", "true") + if user and not trusted: + identification = f"UID={user};PWD={os.getenv('DB_PASSWORD', '')};" + else: + identification = "Trusted_Connection=yes;" + return ( - "Driver={ODBC Driver 17 for SQL Server};" - "Server=LaptopCA\\SQLEXPRESS;" - "Database=DataSentinel;" - "Trusted_Connection=yes;" + f"Driver={{{driver}}};" + f"Server={adresse};" + f"Database={base};" + f"{identification}" + "Encrypt=yes;TrustServerCertificate=yes;" ) diff --git a/docs/openapi.json b/docs/openapi.json index a1a103f..bd19a86 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Data Sentinel API","description":"API de monitoring de la qualité des données — XEFI","version":"1.0.0"},"paths":{"/health":{"get":{"tags":["Système"],"summary":"Health Check","description":"Ping API + test connexion SQL Server.","operationId":"health_check_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/auth/login":{"post":{"tags":["Auth"],"summary":"Login","description":"Authentifie un utilisateur et retourne un JWT.","operationId":"login_auth_login_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_login_auth_login_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/me":{"get":{"tags":["Auth"],"summary":"Me","description":"Profil de l'utilisateur connecté.","operationId":"me_auth_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/admin/users":{"get":{"tags":["Admin"],"summary":"List Users","operationId":"list_users_admin_users_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["Admin"],"summary":"Create User","operationId":"create_user_admin_users_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/admin/users/{id_user}":{"put":{"tags":["Admin"],"summary":"Update User","operationId":"update_user_admin_users__id_user__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Admin"],"summary":"Delete User","description":"Suppression douce (actif = 0).","operationId":"delete_user_admin_users__id_user__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/users/{id_user}/reset-password":{"post":{"tags":["Admin"],"summary":"Reset Password","operationId":"reset_password_admin_users__id_user__reset_password_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordReset"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/journal":{"get":{"tags":["Admin"],"summary":"Get Journal","operationId":"get_journal_admin_journal_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":200,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/me/data-export":{"get":{"tags":["RGPD"],"summary":"Export My Data","description":"Droit à la portabilité (art. 20) : export des données de l'utilisateur.","operationId":"export_my_data_me_data_export_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/me":{"delete":{"tags":["RGPD"],"summary":"Delete My Account","description":"Droit à l'oubli (art. 17) : désactivation + anonymisation.","operationId":"delete_my_account_me_delete","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/categories":{"get":{"tags":["Référentiels"],"summary":"Get Categories","description":"Toutes les catégories de monitoring.","operationId":"get_categories_categories_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/services":{"get":{"tags":["Référentiels"],"summary":"Get Services","description":"Tous les services.","operationId":"get_services_services_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/contacts":{"get":{"tags":["Référentiels"],"summary":"Get Contacts","description":"Contacts, filtrables par service.","operationId":"get_contacts_contacts_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par service","title":"Id Service"},"description":"Filtrer par service"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings":{"get":{"tags":["Monitorings"],"summary":"Get Monitorings","description":"Liste des monitorings actifs avec table_source et bdd_source.","operationId":"get_monitorings_monitorings_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par service","title":"Id Service"},"description":"Filtrer par service"},{"name":"id_categorie","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par catégorie","title":"Id Categorie"},"description":"Filtrer par catégorie"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring By Id","description":"Détail d'un monitoring.","operationId":"get_monitoring_by_id_monitorings__id_monito__get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/details":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Details","description":"Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée.\nToutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend.","operationId":"get_monitoring_details_monitorings__id_monito__details_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}},{"name":"search","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Recherche sur colonnes texte (ct_intitule, agence, STE...)","title":"Search"},"description":"Recherche sur colonnes texte (ct_intitule, agence, STE...)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":5000,"minimum":1,"description":"Lignes max — mettre 5000 pour export CSV complet","default":500,"title":"Limit"},"description":"Lignes max — mettre 5000 pour export CSV complet"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Offset pagination","default":0,"title":"Offset"},"description":"Offset pagination"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/count":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Count","description":"Nombre d'erreurs dans la table dédiée du monitoring.","operationId":"get_monitoring_count_monitorings__id_monito__count_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/columns":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Columns","description":"Retourne les colonnes de la table dédiée.\nPermet au frontend de générer dynamiquement les en-têtes du tableau.","operationId":"get_monitoring_columns_monitorings__id_monito__columns_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/dashboard":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard","description":"Vue consolidée — source principale du dashboard.","operationId":"get_dashboard_dashboard_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"service","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filtrer par service","title":"Service"},"description":"Filtrer par service"},{"name":"categorie","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filtrer par catégorie","title":"Categorie"},"description":"Filtrer par catégorie"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/dashboard/filtres":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard Filtres","description":"Valeurs de filtre réellement présentes dans VUE_CONSO.\n\nLes référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles\naucun monitoring actif n'est rattaché (ex. « Business Intelligence »).\nLes proposer dans les menus déroulants du dashboard mène à un écran vide :\ncet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring.\n\n`combinaisons` permet au frontend de restreindre les catégories proposées\nau service sélectionné.","operationId":"get_dashboard_filtres_dashboard_filtres_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/dashboard/summary":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard Summary","description":"KPI globaux pour les 4 cartes du dashboard.","operationId":"get_dashboard_summary_dashboard_summary_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/historique":{"get":{"tags":["Historique"],"summary":"Get Historique","description":"Snapshots journaliers (TABLE_FINAL).","operationId":"get_historique_historique_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id Monito"}},{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"},{"name":"service","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Service"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/historique/{id_monito}/evolution":{"get":{"tags":["Historique"],"summary":"Get Evolution","description":"Évolution d'un monitoring — format optimisé Recharts.","operationId":"get_evolution_historique__id_monito__evolution_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}},{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Debut"}},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Fin"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/historique/comparaison":{"get":{"tags":["Historique"],"summary":"Get Comparaison","description":"Déprécié — utiliser GET /evolution/par-monitoring à la place.\nConservé pour compatibilité ascendante.","operationId":"get_comparaison_historique_comparaison_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Debut"}},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Fin"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/global":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Global","description":"Courbe globale : total des erreurs tous monitorings confondus, par jour.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL'\nFormat optimisé Recharts → [{date, nb_erreurs}]","operationId":"get_evolution_global_evolution_global_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/par-monitoring":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Par Monitoring","description":"Évolution par monitoring — toutes les courbes individuelles.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO'\n\nRetourne un format pivot par monitoring :\n{\n \"monitorings\": [{\"id\": 1, \"nom\": \"...\", \"serie\": [{\"date\",\"nb_erreurs\"}]}]\n}\nUtile pour un graphique multi-lignes Recharts (une ligne par monitoring).","operationId":"get_evolution_par_monitoring_evolution_par_monitoring_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"},{"name":"id_monito","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer sur un seul monitoring","title":"Id Monito"},"description":"Filtrer sur un seul monitoring"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/par-service":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Par Service","description":"Évolution par service (Contrat / Fournisseur) — 1 courbe par service.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE'\n\nRetourne :\n{\n \"services\": [{\"service\": \"Contrat\", \"serie\": [{\"date\",\"nb_erreurs\"}]}]\n}","operationId":"get_evolution_par_service_evolution_par_service_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"Body_login_auth_login_post":{"properties":{"grant_type":{"anyOf":[{"type":"string","pattern":"^password$"},{"type":"null"}],"title":"Grant Type"},"username":{"type":"string","title":"Username"},"password":{"type":"string","format":"password","title":"Password"},"scope":{"type":"string","title":"Scope","default":""},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"password","title":"Client Secret"}},"type":"object","required":["username","password"],"title":"Body_login_auth_login_post"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"PasswordReset":{"properties":{"password":{"type":"string","title":"Password"}},"type":"object","required":["password"],"title":"PasswordReset"},"UserCreate":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"},"role":{"type":"string","title":"Role"}},"type":"object","required":["username","email","password","role"],"title":"UserCreate"},"UserUpdate":{"properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role"},"actif":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Actif"}},"type":"object","title":"UserUpdate"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{},"tokenUrl":"auth/login"}}}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Data Sentinel API","description":"API de monitoring de la qualité des données — XEFI","version":"1.0.0"},"paths":{"/health":{"get":{"tags":["Système"],"summary":"Health Check","description":"Ping API + test connexion SQL Server.","operationId":"health_check_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/auth/login":{"post":{"tags":["Auth"],"summary":"Login","description":"Authentifie un utilisateur et retourne un JWT.","operationId":"login_auth_login_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_login_auth_login_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/me":{"get":{"tags":["Auth"],"summary":"Me","description":"Profil de l'utilisateur connecté.","operationId":"me_auth_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/categories":{"get":{"tags":["Référentiels"],"summary":"Get Categories","description":"Toutes les catégories de monitoring.","operationId":"get_categories_categories_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["Référentiels"],"summary":"Create Categorie","operationId":"create_categorie_categories_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategorieBody"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/categories/{id_categorie}":{"put":{"tags":["Référentiels"],"summary":"Update Categorie","operationId":"update_categorie_categories__id_categorie__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_categorie","in":"path","required":true,"schema":{"type":"integer","title":"Id Categorie"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategorieBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Référentiels"],"summary":"Delete Categorie","description":"Supprime une catégorie, sauf si des monitorings s'y rattachent encore :\nla clé étrangère FK_NOM_CATEGORIE l'interdirait, autant renvoyer un\nmessage explicite plutôt que de laisser remonter l'erreur SQL brute.","operationId":"delete_categorie_categories__id_categorie__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_categorie","in":"path","required":true,"schema":{"type":"integer","title":"Id Categorie"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/services":{"get":{"tags":["Référentiels"],"summary":"Get Services","description":"Tous les services.","operationId":"get_services_services_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["Référentiels"],"summary":"Create Service","operationId":"create_service_services_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceBody"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/services/{id_service}":{"put":{"tags":["Référentiels"],"summary":"Update Service","operationId":"update_service_services__id_service__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"path","required":true,"schema":{"type":"integer","title":"Id Service"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Référentiels"],"summary":"Delete Service","description":"Refuse la suppression tant que des monitorings ou contacts y sont rattachés.","operationId":"delete_service_services__id_service__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"path","required":true,"schema":{"type":"integer","title":"Id Service"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/contacts":{"get":{"tags":["Référentiels"],"summary":"Get Contacts","description":"Contacts, filtrables par service.","operationId":"get_contacts_contacts_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par service","title":"Id Service"},"description":"Filtrer par service"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Référentiels"],"summary":"Create Contact","operationId":"create_contact_contacts_post","security":[{"OAuth2PasswordBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactBody"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/contacts/{id_contact}":{"put":{"tags":["Référentiels"],"summary":"Update Contact","operationId":"update_contact_contacts__id_contact__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_contact","in":"path","required":true,"schema":{"type":"integer","title":"Id Contact"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Référentiels"],"summary":"Delete Contact","description":"Un contact n'est référencé par aucune autre table : suppression directe.","operationId":"delete_contact_contacts__id_contact__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_contact","in":"path","required":true,"schema":{"type":"integer","title":"Id Contact"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings":{"get":{"tags":["Monitorings"],"summary":"Get Monitorings","description":"Liste des monitorings actifs avec table_source et bdd_source.","operationId":"get_monitorings_monitorings_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_service","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par service","title":"Id Service"},"description":"Filtrer par service"},{"name":"id_categorie","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer par catégorie","title":"Id Categorie"},"description":"Filtrer par catégorie"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Monitorings"],"summary":"Create Monitoring","description":"Déclare un monitoring dans la nomenclature.\n\nAttention : cela n'ajoute que l'entrée de référentiel. Pour qu'il remonte\ndes données, il faut aussi créer la table MONITO_[NOM] correspondante et\nl'ajouter au mapping MONITO_TABLES — voir l'en-tête de data_sentinel_init.sql.","operationId":"create_monitoring_monitorings_post","security":[{"OAuth2PasswordBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MonitoringBody"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}":{"put":{"tags":["Monitorings"],"summary":"Update Monitoring","operationId":"update_monitoring_monitorings__id_monito__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MonitoringUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Monitorings"],"summary":"Desactiver Monitoring","description":"Désactivation (actif = 0), pas de suppression physique : TABLE_FINAL\nréférence id_monito et l'historique doit rester consultable.","operationId":"desactiver_monitoring_monitorings__id_monito__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["Monitorings"],"summary":"Get Monitoring By Id","description":"Détail d'un monitoring.","operationId":"get_monitoring_by_id_monitorings__id_monito__get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/details":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Details","description":"Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée.\nToutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend.","operationId":"get_monitoring_details_monitorings__id_monito__details_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}},{"name":"search","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Recherche sur colonnes texte (ct_intitule, agence, STE...)","title":"Search"},"description":"Recherche sur colonnes texte (ct_intitule, agence, STE...)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":5000,"minimum":1,"description":"Lignes max — mettre 5000 pour export CSV complet","default":500,"title":"Limit"},"description":"Lignes max — mettre 5000 pour export CSV complet"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Offset pagination","default":0,"title":"Offset"},"description":"Offset pagination"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/count":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Count","description":"Nombre d'erreurs dans la table dédiée du monitoring.","operationId":"get_monitoring_count_monitorings__id_monito__count_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/monitorings/{id_monito}/columns":{"get":{"tags":["Monitorings"],"summary":"Get Monitoring Columns","description":"Retourne les colonnes de la table dédiée.\nPermet au frontend de générer dynamiquement les en-têtes du tableau.","operationId":"get_monitoring_columns_monitorings__id_monito__columns_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/dashboard":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard","description":"Vue consolidée — source principale du dashboard.","operationId":"get_dashboard_dashboard_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"service","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filtrer par service","title":"Service"},"description":"Filtrer par service"},{"name":"categorie","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filtrer par catégorie","title":"Categorie"},"description":"Filtrer par catégorie"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/dashboard/filtres":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard Filtres","description":"Valeurs de filtre réellement présentes dans VUE_CONSO.\n\nLes référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles\naucun monitoring actif n'est rattaché (ex. « Business Intelligence »).\nLes proposer dans les menus déroulants du dashboard mène à un écran vide :\ncet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring.\n\n`combinaisons` permet au frontend de restreindre les catégories proposées\nau service sélectionné.","operationId":"get_dashboard_filtres_dashboard_filtres_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/dashboard/summary":{"get":{"tags":["Dashboard"],"summary":"Get Dashboard Summary","description":"KPI globaux pour les 4 cartes du dashboard.","operationId":"get_dashboard_summary_dashboard_summary_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/historique":{"get":{"tags":["Historique"],"summary":"Get Historique","description":"Snapshots journaliers (TABLE_FINAL).","operationId":"get_historique_historique_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id Monito"}},{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"},{"name":"service","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Service"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/historique/comparaison":{"get":{"tags":["Historique"],"summary":"Get Comparaison","description":"Déprécié — utiliser GET /evolution/par-monitoring à la place.\nConservé pour compatibilité ascendante.","operationId":"get_comparaison_historique_comparaison_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Debut"}},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Fin"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/historique/{id_monito}/evolution":{"get":{"tags":["Historique"],"summary":"Get Evolution","description":"Évolution d'un monitoring — format optimisé Recharts.","operationId":"get_evolution_historique__id_monito__evolution_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_monito","in":"path","required":true,"schema":{"type":"integer","title":"Id Monito"}},{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Debut"}},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date Fin"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/global":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Global","description":"Courbe globale : total des erreurs tous monitorings confondus, par jour.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL'\nFormat optimisé Recharts → [{date, nb_erreurs}]","operationId":"get_evolution_global_evolution_global_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/par-monitoring":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Par Monitoring","description":"Évolution par monitoring — toutes les courbes individuelles.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO'\n\nRetourne un format pivot par monitoring :\n{\n \"monitorings\": [{\"id\": 1, \"nom\": \"...\", \"serie\": [{\"date\",\"nb_erreurs\"}]}]\n}\nUtile pour un graphique multi-lignes Recharts (une ligne par monitoring).","operationId":"get_evolution_par_monitoring_evolution_par_monitoring_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"},{"name":"id_monito","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filtrer sur un seul monitoring","title":"Id Monito"},"description":"Filtrer sur un seul monitoring"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evolution/par-service":{"get":{"tags":["Évolution globale"],"summary":"Get Evolution Par Service","description":"Évolution par service (Contrat / Fournisseur) — 1 courbe par service.\nSource : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE'\n\nRetourne :\n{\n \"services\": [{\"service\": \"Contrat\", \"serie\": [{\"date\",\"nb_erreurs\"}]}]\n}","operationId":"get_evolution_par_service_evolution_par_service_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date_debut","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Debut"},"description":"YYYY-MM-DD"},{"name":"date_fin","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"YYYY-MM-DD","title":"Date Fin"},"description":"YYYY-MM-DD"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/users":{"get":{"tags":["Admin"],"summary":"List Users","operationId":"list_users_admin_users_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["Admin"],"summary":"Create User","operationId":"create_user_admin_users_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/admin/users/{id_user}":{"put":{"tags":["Admin"],"summary":"Update User","operationId":"update_user_admin_users__id_user__put","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Admin"],"summary":"Delete User","description":"Suppression douce (actif = 0).","operationId":"delete_user_admin_users__id_user__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/users/{id_user}/reset-password":{"post":{"tags":["Admin"],"summary":"Reset Password","operationId":"reset_password_admin_users__id_user__reset_password_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"id_user","in":"path","required":true,"schema":{"type":"integer","title":"Id User"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordReset"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/journal":{"get":{"tags":["Admin"],"summary":"Get Journal","operationId":"get_journal_admin_journal_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":200,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/me/data-export":{"get":{"tags":["RGPD"],"summary":"Export My Data","description":"Droit à la portabilité (art. 20) : export des données de l'utilisateur.","operationId":"export_my_data_me_data_export_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/me":{"delete":{"tags":["RGPD"],"summary":"Delete My Account","description":"Droit à l'oubli (art. 17) : désactivation + anonymisation.","operationId":"delete_my_account_me_delete","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"OAuth2PasswordBearer":[]}]}}},"components":{"schemas":{"Body_login_auth_login_post":{"properties":{"grant_type":{"anyOf":[{"type":"string","pattern":"^password$"},{"type":"null"}],"title":"Grant Type"},"username":{"type":"string","title":"Username"},"password":{"type":"string","format":"password","title":"Password"},"scope":{"type":"string","title":"Scope","default":""},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"password","title":"Client Secret"}},"type":"object","required":["username","password"],"title":"Body_login_auth_login_post"},"CategorieBody":{"properties":{"intitule_categorie":{"type":"string","maxLength":100,"minLength":1,"title":"Intitule Categorie"}},"type":"object","required":["intitule_categorie"],"title":"CategorieBody"},"ContactBody":{"properties":{"id_service":{"type":"integer","title":"Id Service"},"intitule_contact":{"type":"string","maxLength":100,"minLength":1,"title":"Intitule Contact"},"nom":{"type":"string","maxLength":100,"minLength":1,"title":"Nom"},"prenom":{"type":"string","maxLength":100,"minLength":1,"title":"Prenom"},"mail":{"type":"string","maxLength":200,"minLength":1,"title":"Mail"}},"type":"object","required":["id_service","intitule_contact","nom","prenom","mail"],"title":"ContactBody"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"MonitoringBody":{"properties":{"monito_intitule":{"type":"string","maxLength":200,"minLength":1,"title":"Monito Intitule"},"id_service":{"type":"integer","title":"Id Service"},"id_categorie":{"type":"integer","title":"Id Categorie"},"table_source":{"type":"string","maxLength":100,"minLength":1,"title":"Table Source"},"bdd_source":{"type":"string","maxLength":50,"minLength":1,"title":"Bdd Source"}},"type":"object","required":["monito_intitule","id_service","id_categorie","table_source","bdd_source"],"title":"MonitoringBody"},"MonitoringUpdate":{"properties":{"monito_intitule":{"anyOf":[{"type":"string","maxLength":200,"minLength":1},{"type":"null"}],"title":"Monito Intitule"},"id_service":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id Service"},"id_categorie":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Id Categorie"},"table_source":{"anyOf":[{"type":"string","maxLength":100,"minLength":1},{"type":"null"}],"title":"Table Source"},"bdd_source":{"anyOf":[{"type":"string","maxLength":50,"minLength":1},{"type":"null"}],"title":"Bdd Source"},"actif":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Actif"}},"type":"object","title":"MonitoringUpdate"},"PasswordReset":{"properties":{"password":{"type":"string","title":"Password"}},"type":"object","required":["password"],"title":"PasswordReset"},"ServiceBody":{"properties":{"nom_service":{"type":"string","maxLength":100,"minLength":1,"title":"Nom Service"}},"type":"object","required":["nom_service"],"title":"ServiceBody"},"UserCreate":{"properties":{"username":{"type":"string","title":"Username"},"email":{"type":"string","title":"Email"},"password":{"type":"string","title":"Password"},"role":{"$ref":"#/components/schemas/UserRole"}},"type":"object","required":["username","email","password","role"],"title":"UserCreate"},"UserRole":{"type":"string","enum":["Admin","Superviseur","Consultant"],"title":"UserRole","description":"Rôles applicatifs — miroir de la contrainte CK_USER_ROLE en base."},"UserUpdate":{"properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"role":{"anyOf":[{"$ref":"#/components/schemas/UserRole"},{"type":"null"}]},"actif":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Actif"}},"type":"object","title":"UserUpdate"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{},"tokenUrl":"auth/login"}}}}}} \ No newline at end of file diff --git a/domain.py b/domain.py new file mode 100644 index 0000000..82b8b9b --- /dev/null +++ b/domain.py @@ -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"], +} diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..595fa3e --- /dev/null +++ b/helpers.py @@ -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 + ) diff --git a/main.py b/main.py index 4dc1ffd..ec31b9b 100644 --- a/main.py +++ b/main.py @@ -3,27 +3,34 @@ # COYAUD Anthony | 2026 # V2 : une table dédiée par monitoring # +# Ce module ne contient que l'assemblage de l'application : +# configuration, middlewares et montage des routeurs. Chaque +# domaine fonctionnel vit dans routers/. +# # Lancement : uvicorn main:app --reload --port 8000 # Swagger : http://localhost:8000/docs # ============================================================ import os -from fastapi import FastAPI, HTTPException, Query, APIRouter, Depends, Request +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import OAuth2PasswordRequestForm -from pydantic import BaseModel -from typing import Optional -from datetime import date -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.util import get_remote_address +from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from config import Config, get_cursor -from auth import ( - verify_password, hash_password, create_access_token, - get_current_user, require_admin, +from domain import MONITO_TABLES +from rate_limit import limiter +from routers import ( + admin, + authentification, + dashboard, + evolution, + historique, + monitorings, + referentiels, + rgpd, ) # Origines autorisées : depuis CORS_ORIGINS (séparées par des virgules) en prod, @@ -45,7 +52,6 @@ app = FastAPI( ) # Limiteur de débit (anti brute-force sur /auth/login). -limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) @@ -69,570 +75,8 @@ async def security_headers(request: Request, call_next): return response -# Toutes les routes "données" passent par ce router, protégé par JWT. -# /health et /auth/* restent publics (déclarés sur `app`). -router = APIRouter(dependencies=[Depends(get_current_user)]) - # ============================================================ -# Mapping id_monito → table SQL dédiée -# Pour ajouter un monitoring : ajouter une entrée ici. -# ============================================================ - -MONITO_TABLES: dict[int, str] = { - 1: "MONITO_TIERS_PAYEURS", - 2: "MONITO_PRELEVEMENT_SANS_RIB", - 3: "MONITO_DOM_TOM_TVA", - 4: "MONITO_ZONE_VENTES_CRM", - 5: "MONITO_MULTI_MODES_REGLEMENT", - 6: "MONITO_SANS_SIRET", - 7: "MONITO_SANS_CONTREPARTIE", -} - -# Colonnes de recherche textuelle par table (pour le filtre search) -SEARCH_COLS: dict[str, list[str]] = { - "MONITO_TIERS_PAYEURS": ["CT_Intitule", "origineClient", "STE"], - "MONITO_PRELEVEMENT_SANS_RIB": ["CT_Intitule", "STE"], - "MONITO_DOM_TOM_TVA": ["CT_Intitule", "agence", "CT_Pays"], - "MONITO_ZONE_VENTES_CRM": ["name", "Agence", "xefi_sagedatabase"], - "MONITO_MULTI_MODES_REGLEMENT": ["CT_Intitule", "Agence"], - "MONITO_SANS_SIRET": ["ct_intitule", "agence", "ct_num"], - "MONITO_SANS_CONTREPARTIE": ["agence", "EC_Piece", "CT_NumCont"], -} - - -# ============================================================ -# Helpers -# ============================================================ - -def rows_to_list(cursor, rows) -> list[dict]: - """Convertit les lignes pyodbc en liste de dicts.""" - cols = [col[0] for col in cursor.description] - return [dict(zip(cols, r)) for r in rows] - - -def row_to_dict(cursor, row) -> dict: - """Convertit une ligne pyodbc en dict.""" - cols = [col[0] for col in cursor.description] - return dict(zip(cols, row)) - - -def get_table_name(id_monito: int) -> str: - """Retourne le nom de la table dédiée. Lève 404 si inconnu.""" - table = MONITO_TABLES.get(id_monito) - if not table: - raise HTTPException( - status_code=404, - detail=f"Monitoring {id_monito} introuvable. IDs valides : {list(MONITO_TABLES.keys())}" - ) - return table - - -def serialize_row(row: dict) -> dict: - """Convertit les types non-JSON (date, Decimal) en types sérialisables.""" - result = {} - for k, v in row.items(): - if v is None: - result[k] = None - elif hasattr(v, 'isoformat'): - result[k] = v.isoformat() - else: - result[k] = v - return result - - -# ============================================================ -# RÉFÉRENTIELS -# ============================================================ - -@router.get("/categories", tags=["Référentiels"]) -def get_categories(): - """Toutes les catégories de monitoring.""" - with get_cursor() as cursor: - cursor.execute( - "SELECT id_categorie, intitule_categorie " - "FROM CATEGORIE ORDER BY intitule_categorie" - ) - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/services", tags=["Référentiels"]) -def get_services(): - """Tous les services.""" - with get_cursor() as cursor: - cursor.execute("SELECT id_service, nom_service FROM SERVICE ORDER BY nom_service") - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/contacts", tags=["Référentiels"]) -def get_contacts( - id_service: Optional[int] = Query(None, description="Filtrer par service") -): - """Contacts, filtrables par service.""" - with get_cursor() as cursor: - if id_service: - cursor.execute( - "SELECT id_contact, id_service, intitule_contact, nom, prenom, mail " - "FROM CONTACT WHERE id_service = ? ORDER BY nom", - id_service - ) - else: - cursor.execute( - "SELECT id_contact, id_service, intitule_contact, nom, prenom, mail " - "FROM CONTACT ORDER BY nom" - ) - return rows_to_list(cursor, cursor.fetchall()) - - -# ============================================================ -# NOMENCLATURE -# ============================================================ - -@router.get("/monitorings", tags=["Monitorings"]) -def get_monitorings( - id_service : Optional[int] = Query(None, description="Filtrer par service"), - id_categorie : Optional[int] = Query(None, description="Filtrer par catégorie"), -): - """Liste des monitorings actifs avec table_source et bdd_source.""" - query = ( - "SELECT id_monito, monito_intitule, id_service, id_categorie, " - "table_source, bdd_source " - "FROM NOMENCLATURE_MONITO WHERE actif = 1" - ) - params = [] - - if id_service: - query += " AND id_service = ?" - params.append(id_service) - if id_categorie: - query += " AND id_categorie = ?" - params.append(id_categorie) - - query += " ORDER BY id_monito" - - with get_cursor() as cursor: - cursor.execute(query, *params) - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/monitorings/{id_monito}", tags=["Monitorings"]) -def get_monitoring_by_id(id_monito: int): - """Détail d'un monitoring.""" - with get_cursor() as cursor: - cursor.execute( - "SELECT id_monito, monito_intitule, id_service, id_categorie, " - "table_source, bdd_source " - "FROM NOMENCLATURE_MONITO WHERE id_monito = ? AND actif = 1", - id_monito - ) - row = cursor.fetchone() - if not row: - raise HTTPException(status_code=404, detail=f"Monitoring {id_monito} introuvable.") - return row_to_dict(cursor, row) - - -# ============================================================ -# DONNÉES DÉTAILLÉES — table dédiée par monitoring -# ============================================================ - -@router.get("/monitorings/{id_monito}/details", tags=["Monitorings"]) -def get_monitoring_details( - id_monito : int, - search : Optional[str] = Query( - None, - description="Recherche sur colonnes texte (ct_intitule, agence, STE...)" - ), - limit : int = Query(500, ge=1, le=5000, description="Lignes max — mettre 5000 pour export CSV complet"), - offset : int = Query(0, ge=0, description="Offset pagination"), -): - """ - Retourne toutes les lignes en erreur depuis la table MONITO_[NOM] dédiée. - Toutes les colonnes métier sont exposées → utilisable pour export CSV côté frontend. - """ - table = get_table_name(id_monito) - query = f"SELECT * FROM {table} WHERE 1=1" - params = [] - - if search and table in SEARCH_COLS: - cols = SEARCH_COLS[table] - conditions = " OR ".join([f"{col} LIKE ?" for col in cols]) - query += f" AND ({conditions})" - params.extend([f"%{search}%"] * len(cols)) - - query += " ORDER BY date_extraction DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" - params += [offset, limit] - - with get_cursor() as cursor: - cursor.execute(query, *params) - rows = rows_to_list(cursor, cursor.fetchall()) - return [serialize_row(r) for r in rows] - - -@router.get("/monitorings/{id_monito}/count", tags=["Monitorings"]) -def get_monitoring_count(id_monito: int): - """Nombre d'erreurs dans la table dédiée du monitoring.""" - table = get_table_name(id_monito) - with get_cursor() as cursor: - cursor.execute(f"SELECT COUNT(*) FROM {table}") - return { - "id_monito" : id_monito, - "table" : table, - "nb_erreurs": cursor.fetchone()[0], - } - - -@router.get("/monitorings/{id_monito}/columns", tags=["Monitorings"]) -def get_monitoring_columns(id_monito: int): - """ - Retourne les colonnes de la table dédiée. - Permet au frontend de générer dynamiquement les en-têtes du tableau. - """ - table = get_table_name(id_monito) - with get_cursor() as cursor: - cursor.execute( - "SELECT COLUMN_NAME, DATA_TYPE " - "FROM INFORMATION_SCHEMA.COLUMNS " - "WHERE TABLE_NAME = ? ORDER BY ORDINAL_POSITION", - table - ) - return { - "id_monito": id_monito, - "table" : table, - "columns" : rows_to_list(cursor, cursor.fetchall()), - } - - -# ============================================================ -# DASHBOARD — VUE_CONSO -# ============================================================ - -@router.get("/dashboard", tags=["Dashboard"]) -def get_dashboard( - service : Optional[str] = Query(None, description="Filtrer par service"), - categorie : Optional[str] = Query(None, description="Filtrer par catégorie"), -): - """Vue consolidée — source principale du dashboard.""" - query = ( - "SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source " - "FROM VUE_CONSO WHERE 1=1" - ) - params = [] - - if service: - query += " AND service = ?" - params.append(service) - if categorie: - query += " AND categorie = ?" - params.append(categorie) - - query += " ORDER BY nb_erreurs DESC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/dashboard/filtres", tags=["Dashboard"]) -def get_dashboard_filtres(): - """ - Valeurs de filtre réellement présentes dans VUE_CONSO. - - Les référentiels SERVICE / CATEGORIE contiennent des entrées auxquelles - aucun monitoring actif n'est rattaché (ex. « Business Intelligence »). - Les proposer dans les menus déroulants du dashboard mène à un écran vide : - cet endpoint ne renvoie que les valeurs qui ramènent au moins un monitoring. - - `combinaisons` permet au frontend de restreindre les catégories proposées - au service sélectionné. - """ - with get_cursor() as cursor: - cursor.execute( - "SELECT DISTINCT service, categorie FROM VUE_CONSO " - "ORDER BY service, categorie" - ) - combinaisons = [{"service": r[0], "categorie": r[1]} for r in cursor.fetchall()] - - return { - "services" : sorted({c["service"] for c in combinaisons}), - "categories" : sorted({c["categorie"] for c in combinaisons}), - "combinaisons" : combinaisons, - } - - -@router.get("/dashboard/summary", tags=["Dashboard"]) -def get_dashboard_summary(): - """KPI globaux pour les 4 cartes du dashboard.""" - with get_cursor() as cursor: - cursor.execute(""" - SELECT - COUNT(*) AS nb_monitorings, - SUM(nb_erreurs) AS total_erreurs, - MAX(nb_erreurs) AS max_erreurs, - SUM(CASE WHEN nb_erreurs = 0 THEN 1 ELSE 0 END) AS monitorings_ok, - SUM(CASE WHEN nb_erreurs > 0 THEN 1 ELSE 0 END) AS monitorings_en_erreur - FROM VUE_CONSO - """) - summary = row_to_dict(cursor, cursor.fetchone()) - - cursor.execute( - "SELECT TOP 1 id_monito, nom_monito, nb_erreurs, service, bdd_source " - "FROM VUE_CONSO ORDER BY nb_erreurs DESC" - ) - top = cursor.fetchone() - summary["monitoring_critique"] = row_to_dict(cursor, top) if top else None - - return summary - - -# ============================================================ -# HISTORIQUE — TABLE_FINAL -# ============================================================ - -@router.get("/historique", tags=["Historique"]) -def get_historique( - id_monito : Optional[int] = Query(None), - date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), - date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), - service : Optional[str] = Query(None), -): - """Snapshots journaliers (TABLE_FINAL).""" - query = ( - "SELECT id_monito, nom_monito, nb_erreurs, service, categorie, bdd_source, " - "CONVERT(NVARCHAR, date_sauvegarde, 23) AS date_sauvegarde " - "FROM TABLE_FINAL WHERE 1=1" - ) - params = [] - - if id_monito: - query += " AND id_monito = ?" - params.append(id_monito) - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - if service: - query += " AND service = ?" - params.append(service) - - query += " ORDER BY date_sauvegarde ASC, id_monito ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - return rows_to_list(cursor, cursor.fetchall()) - - -@router.get("/historique/{id_monito}/evolution", tags=["Historique"]) -def get_evolution( - id_monito : int, - date_debut : Optional[date] = Query(None), - date_fin : Optional[date] = Query(None), -): - """Évolution d'un monitoring — format optimisé Recharts.""" - get_table_name(id_monito) # valide l'existence du monitoring - - query = ( - "SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, nb_erreurs " - "FROM TABLE_FINAL WHERE id_monito = ?" - ) - params = [id_monito] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - - query += " ORDER BY date_sauvegarde ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()] - - return {"id_monito": id_monito, "points": len(data), "evolution": data} - - -@router.get("/historique/comparaison", tags=["Historique"]) -def get_comparaison( - date_debut : Optional[date] = Query(None), - date_fin : Optional[date] = Query(None), -): - """ - Déprécié — utiliser GET /evolution/par-monitoring à la place. - Conservé pour compatibilité ascendante. - """ - query = ( - "SELECT CONVERT(NVARCHAR, date_sauvegarde, 23) AS date, " - "id_monito, nom_monito, nb_erreurs, service " - "FROM TABLE_FINAL WHERE 1=1" - ) - params = [] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - - query += " ORDER BY date_sauvegarde ASC, id_monito ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - return rows_to_list(cursor, cursor.fetchall()) - - -# ============================================================ -# ÉVOLUTION GLOBALE — VUE_TABLE_FINAL_CONSO -# Vue qui agrège TABLE_FINAL en 3 niveaux : -# GLOBAL → total toutes monitorings confondues (courbe principale) -# MONITO → détail par monitoring (courbes individuelles) -# SERVICE → regroupement par service (Contrat / Fournisseur) -# ============================================================ - -@router.get("/evolution/global", tags=["Évolution globale"]) -def get_evolution_global( - date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), - date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), -): - """ - Courbe globale : total des erreurs tous monitorings confondus, par jour. - Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'GLOBAL' - Format optimisé Recharts → [{date, nb_erreurs}] - """ - query = ( - "SELECT date_sauvegarde AS date, nb_erreurs " - "FROM VUE_TABLE_FINAL_CONSO " - "WHERE type_agregat = 'GLOBAL'" - ) - params = [] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - - query += " ORDER BY date_sauvegarde ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - data = [{"date": r[0], "nb_erreurs": r[1]} for r in cursor.fetchall()] - - return { - "type" : "GLOBAL", - "points" : len(data), - "series" : data, - } - - -@router.get("/evolution/par-monitoring", tags=["Évolution globale"]) -def get_evolution_par_monitoring( - date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), - date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), - id_monito : Optional[int] = Query(None, description="Filtrer sur un seul monitoring"), -): - """ - Évolution par monitoring — toutes les courbes individuelles. - Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'MONITO' - - Retourne un format pivot par monitoring : - { - "monitorings": [{"id": 1, "nom": "...", "serie": [{"date","nb_erreurs"}]}] - } - Utile pour un graphique multi-lignes Recharts (une ligne par monitoring). - """ - query = ( - "SELECT date_sauvegarde AS date, id_monito, nom_monito, nb_erreurs " - "FROM VUE_TABLE_FINAL_CONSO " - "WHERE type_agregat = 'MONITO'" - ) - params = [] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - if id_monito: - query += " AND id_monito = ?" - params.append(id_monito) - - query += " ORDER BY id_monito ASC, date_sauvegarde ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - rows = cursor.fetchall() - - # Pivot : regrouper par monitoring - pivot: dict[int, dict] = {} - for row in rows: - date, mid, nom, nb = row[0], row[1], row[2], row[3] - if mid not in pivot: - pivot[mid] = {"id_monito": mid, "nom_monito": nom, "serie": []} - pivot[mid]["serie"].append({"date": date, "nb_erreurs": nb}) - - return { - "type" : "MONITO", - "nb_series" : len(pivot), - "monitorings" : list(pivot.values()), - } - - -@router.get("/evolution/par-service", tags=["Évolution globale"]) -def get_evolution_par_service( - date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), - date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), -): - """ - Évolution par service (Contrat / Fournisseur) — 1 courbe par service. - Source : VUE_TABLE_FINAL_CONSO WHERE type_agregat = 'SERVICE' - - Retourne : - { - "services": [{"service": "Contrat", "serie": [{"date","nb_erreurs"}]}] - } - """ - query = ( - "SELECT date_sauvegarde AS date, nom_monito AS service, nb_erreurs " - "FROM VUE_TABLE_FINAL_CONSO " - "WHERE type_agregat = 'SERVICE'" - ) - params = [] - - if date_debut: - query += " AND date_sauvegarde >= ?" - params.append(str(date_debut)) - if date_fin: - query += " AND date_sauvegarde <= ?" - params.append(str(date_fin)) - - query += " ORDER BY nom_monito ASC, date_sauvegarde ASC" - - with get_cursor() as cursor: - cursor.execute(query, *params) - rows = cursor.fetchall() - - # Pivot par service - pivot: dict[str, list] = {} - for row in rows: - date, service, nb = row[0], row[1], row[2] - if service not in pivot: - pivot[service] = [] - pivot[service].append({"date": date, "nb_erreurs": nb}) - - return { - "type" : "SERVICE", - "nb_series": len(pivot), - "services" : [{"service": s, "serie": v} for s, v in pivot.items()], - } - - -# ============================================================ -# SANTÉ +# SANTÉ — route publique # ============================================================ @app.get("/health", tags=["Système"]) @@ -655,175 +99,17 @@ def health_check(): # ============================================================ -# AUTHENTIFICATION +# Montage des routeurs +# +# Les routeurs de données déclarent eux-mêmes leur dépendance +# d'authentification. /health et /auth/login restent publics. # ============================================================ -class UserCreate(BaseModel): - username: str - email: str - password: str - role: str - -class UserUpdate(BaseModel): - email: Optional[str] = None - role: Optional[str] = None - actif: Optional[bool] = None - -class PasswordReset(BaseModel): - password: str - - -@app.post("/auth/login", tags=["Auth"]) -@limiter.limit("5/minute") -def login(request: Request, form: OAuth2PasswordRequestForm = Depends()): - """Authentifie un utilisateur et retourne un JWT.""" - with get_cursor() as cursor: - cursor.execute( - "SELECT id_user, username, password_hash, role, actif " - "FROM [USER] WHERE username = ?", form.username - ) - row = cursor.fetchone() - if not row or not row[4] or not verify_password(form.password, row[2]): - raise HTTPException(status_code=401, detail="Identifiants invalides") - ip = request.client.host if request.client else None - cursor.execute("UPDATE [USER] SET last_login = SYSDATETIME() WHERE id_user = ?", row[0]) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, ip) VALUES (?, ?, 'LOGIN', ?)", - row[0], row[1], ip - ) - token = create_access_token({"sub": row[1], "role": row[3], "uid": row[0]}) - return {"access_token": token, "token_type": "bearer", - "user": {"username": row[1], "role": row[3]}} - - -@app.get("/auth/me", tags=["Auth"]) -def me(user: dict = Depends(get_current_user)): - """Profil de l'utilisateur connecté.""" - return user - - -# ============================================================ -# ADMINISTRATION (réservé Admin) -# ============================================================ - -@app.get("/admin/users", tags=["Admin"]) -def list_users(admin: dict = Depends(require_admin)): - with get_cursor() as cursor: - cursor.execute( - "SELECT id_user, username, email, role, actif, created_at, last_login " - "FROM [USER] ORDER BY id_user" - ) - return rows_to_list(cursor, cursor.fetchall()) - - -@app.post("/admin/users", tags=["Admin"], status_code=201) -def create_user(body: UserCreate, admin: dict = Depends(require_admin)): - if body.role not in ("Admin", "Superviseur", "Consultant"): - raise HTTPException(status_code=400, detail="Rôle invalide") - with get_cursor() as cursor: - cursor.execute( - "INSERT INTO [USER] (username, email, password_hash, role) VALUES (?, ?, ?, ?)", - body.username, body.email, hash_password(body.password), body.role - ) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'CREATE_USER', ?)", - admin["id_user"], admin["username"], body.username - ) - return {"status": "created", "username": body.username} - - -@app.put("/admin/users/{id_user}", tags=["Admin"]) -def update_user(id_user: int, body: UserUpdate, admin: dict = Depends(require_admin)): - sets, params = [], [] - if body.email is not None: - sets.append("email = ?"); params.append(body.email) - if body.role is not None: - if body.role not in ("Admin", "Superviseur", "Consultant"): - raise HTTPException(status_code=400, detail="Rôle invalide") - sets.append("role = ?"); params.append(body.role) - if body.actif is not None: - sets.append("actif = ?"); params.append(1 if body.actif else 0) - if not sets: - raise HTTPException(status_code=400, detail="Aucun champ à modifier") - params.append(id_user) - with get_cursor() as cursor: - cursor.execute(f"UPDATE [USER] SET {', '.join(sets)} WHERE id_user = ?", *params) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'UPDATE_USER', ?)", - admin["id_user"], admin["username"], str(id_user) - ) - return {"status": "updated", "id_user": id_user} - - -@app.delete("/admin/users/{id_user}", tags=["Admin"]) -def delete_user(id_user: int, admin: dict = Depends(require_admin)): - """Suppression douce (actif = 0).""" - with get_cursor() as cursor: - cursor.execute("UPDATE [USER] SET actif = 0 WHERE id_user = ?", id_user) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'DELETE_USER', ?)", - admin["id_user"], admin["username"], str(id_user) - ) - return {"status": "deactivated", "id_user": id_user} - - -@app.post("/admin/users/{id_user}/reset-password", tags=["Admin"]) -def reset_password(id_user: int, body: PasswordReset, admin: dict = Depends(require_admin)): - with get_cursor() as cursor: - cursor.execute("UPDATE [USER] SET password_hash = ? WHERE id_user = ?", - hash_password(body.password), id_user) - cursor.execute( - "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'RESET_PASSWORD', ?)", - admin["id_user"], admin["username"], str(id_user) - ) - return {"status": "password_reset", "id_user": id_user} - - -@app.get("/admin/journal", tags=["Admin"]) -def get_journal(limit: int = 200, admin: dict = Depends(require_admin)): - with get_cursor() as cursor: - cursor.execute( - "SELECT TOP (?) date_action, username, action, detail, ip " - "FROM JOURNAL_AUDIT ORDER BY date_action DESC", limit - ) - return rows_to_list(cursor, cursor.fetchall()) - - -# ============================================================ -# RGPD — droits de la personne -# ============================================================ - -@app.get("/me/data-export", tags=["RGPD"]) -def export_my_data(user: dict = Depends(get_current_user)): - """Droit à la portabilité (art. 20) : export des données de l'utilisateur.""" - with get_cursor() as cursor: - cursor.execute( - "SELECT id_user, username, email, role, actif, created_at, last_login " - "FROM [USER] WHERE username = ?", user["username"] - ) - u = cursor.fetchone() - profile = row_to_dict(cursor, u) if u else {} - cursor.execute( - "SELECT date_action, action, detail, ip FROM JOURNAL_AUDIT " - "WHERE username = ? ORDER BY date_action DESC", user["username"] - ) - journal = rows_to_list(cursor, cursor.fetchall()) - return {"profile": profile, "journal": journal} - - -@app.delete("/me", tags=["RGPD"]) -def delete_my_account(user: dict = Depends(get_current_user)): - """Droit à l'oubli (art. 17) : désactivation + anonymisation.""" - with get_cursor() as cursor: - cursor.execute( - "UPDATE [USER] SET actif = 0, " - "username = CONCAT('deleted_', id_user), " - "email = CONCAT('deleted_', id_user, '@deleted.local') " - "WHERE username = ?", user["username"] - ) - cursor.execute("UPDATE JOURNAL_AUDIT SET username = NULL WHERE username = ?", user["username"]) - return {"status": "account_deleted"} - - -# Enregistre les routes "données" protégées par JWT. -app.include_router(router) +app.include_router(authentification.router) +app.include_router(referentiels.router) +app.include_router(monitorings.router, prefix="/monitorings") +app.include_router(dashboard.router, prefix="/dashboard") +app.include_router(historique.router, prefix="/historique") +app.include_router(evolution.router, prefix="/evolution") +app.include_router(admin.router) +app.include_router(rgpd.router) diff --git a/rate_limit.py b/rate_limit.py new file mode 100644 index 0000000..060e532 --- /dev/null +++ b/rate_limit.py @@ -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) diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/admin.py b/routers/admin.py new file mode 100644 index 0000000..1615d3f --- /dev/null +++ b/routers/admin.py @@ -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()) diff --git a/routers/authentification.py b/routers/authentification.py new file mode 100644 index 0000000..f5faf34 --- /dev/null +++ b/routers/authentification.py @@ -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 diff --git a/routers/dashboard.py b/routers/dashboard.py new file mode 100644 index 0000000..5f3bb65 --- /dev/null +++ b/routers/dashboard.py @@ -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 diff --git a/routers/evolution.py b/routers/evolution.py new file mode 100644 index 0000000..b7e3ff5 --- /dev/null +++ b/routers/evolution.py @@ -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()], + } diff --git a/routers/historique.py b/routers/historique.py new file mode 100644 index 0000000..1a8831a --- /dev/null +++ b/routers/historique.py @@ -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} diff --git a/routers/monitorings.py b/routers/monitorings.py new file mode 100644 index 0000000..b4327bd --- /dev/null +++ b/routers/monitorings.py @@ -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()), + } diff --git a/routers/referentiels.py b/routers/referentiels.py new file mode 100644 index 0000000..31f17bb --- /dev/null +++ b/routers/referentiels.py @@ -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} diff --git a/routers/rgpd.py b/routers/rgpd.py new file mode 100644 index 0000000..d48db4c --- /dev/null +++ b/routers/rgpd.py @@ -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"} diff --git a/tests/conftest.py b/tests/conftest.py index 3b54a3f..1e34eb5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,23 @@ from fastapi.testclient import TestClient import main from auth import create_access_token +from routers import ( + admin, + authentification, + dashboard, + evolution, + historique, + monitorings, + referentiels, + rgpd, +) + +# Modules qui ont fait `from config import get_cursor` : l'import lie le nom +# dans leur propre espace de noms, il faut donc le remplacer dans chacun. +MODULES_AVEC_CURSEUR = ( + main, admin, authentification, dashboard, evolution, + historique, monitorings, referentiels, rgpd, +) class FakeCursor: @@ -17,6 +34,7 @@ class FakeCursor: self.description = [] self._rows = [] self._one = None + self.rowcount = 1 self.executed = [] def execute(self, query, *args): @@ -38,7 +56,9 @@ def cur(monkeypatch): def fake_get_cursor(): yield c - monkeypatch.setattr(main, "get_cursor", fake_get_cursor) + for module in MODULES_AVEC_CURSEUR: + if hasattr(module, "get_cursor"): + monkeypatch.setattr(module, "get_cursor", fake_get_cursor) return c diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 96c2958..091b79c 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -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}"})