Merge remote-tracking branch 'origin/dev' into feat/endpoint-sites-current

# Conflicts:
#	apps/backend/app/repositories/reading.py
#	apps/backend/openapi.json
#	docs/architecture/20-backend.md
This commit is contained in:
Johan LEROY
2026-09-17 12:17:05 +02:00
58 changed files with 4556 additions and 41 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|---|---|---|---|---|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites` et `recommendations` en lecture (endpoints → services → repositories → models) |
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
+41 -24
View File
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
ep["endpoints<br/>health, auth, users, sites,<br/>recommendations, stats"]
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, sensors"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService, RecommendationService,<br/>StatsService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, recommendation, reading"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, SensorService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading"]
md["models<br/>10 tables"]
db[("PostgreSQL")]
@@ -147,6 +147,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 |
| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
@@ -159,24 +161,37 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f
donc de modifier la liste dans ce fichier de test.
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
`GET /alerts` puis pour les suivantes (`reading`, `dataset`, `prediction`, `recommendation`) : les
quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des
tables déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`,
contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par
`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par
`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base
réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même
gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne
porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore
exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`)
dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit
route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`, mais
`SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la
dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de
mesure à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`.
Le contrat détaillé pour le frontend est dans
`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches
`endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées
par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux
routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une
ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par `execute()`, ce qui la rend
testable par la fixture `fake_session` au niveau endpoint sans base réelle. `GET /recommendations`
et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la lettre,
`recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne porte pas `site_id` :
elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET
/stats/summary` et `GET /sensors/status` agrègent chacune deux repositories (`SiteRepository`,
`ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elles n'entrent donc
pas dans ce gabarit route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`,
mais `SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la
dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de mesure
à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. Le contrat
détaillé pour le frontend est dans
[31-contrat-authentification.md](31-contrat-authentification.md).
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/
owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni
fenêtre temporelle maximale). `ReadingService` porte donc une couche de validation absente des
autres routes de lecture : `start`/`end` sont optionnels (24 dernières heures par défaut si les
deux sont omis, l'un défaut par rapport à l'autre sinon), l'écart entre les deux est plafonné à 90
jours (`FENETRE_MAXIMALE`), et `limit`/`offset` (défaut 500, plafond 2000) empêchent qu'une fenêtre
large mais peu dense reste malgré tout coûteuse. Un dépassement de plafond répond `400` (règle
métier, portée par le service) plutôt que `422` (réservé à la validation structurelle de FastAPI,
par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est traité comme de
l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait
côté pilote, en `500` plutôt qu'un refus propre.
### `/health/ready`
Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un
@@ -251,8 +266,8 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
### Ajouter une route métier
Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`
(`reading`, `dataset`, `prediction`) :
Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/
`readings`/`sensors` (`dataset`, `prediction`) :
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN`
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
@@ -329,7 +344,9 @@ Trois fichiers méritent d'être connus avant de toucher à l'authentification :
agir sur le site B. C'est la limite connue du modèle, et le risque BOLA du top 10 API.
- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur
`audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004.
- **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des
endpoints métier. Sans plafond dur, une requête sur dix ans d'historique suffit à faire tomber
l'API.
- **Pagination et fenêtrage** : posés sur `GET /readings` (fenêtre plafonnée à 90 jours,
`limit`/`offset` plafonné à 2000), mais toujours en `limit`/`offset` simple — pas de curseur ni
de plan de secours si un `offset` élevé sur une fenêtre dense devient lent en pratique.
`statement_timeout` reste absent au niveau de la connexion, donc rien n'empêche une requête
individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants.
- **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`.
+70
View File
@@ -231,3 +231,73 @@ et ne sont pas considérées comme des alertes actuelles.
- Les mesures API ne sont pas rattachées à un dataset historique.
- Une alerte peut être associée à une prévision du même site.
- Une alerte peut donner lieu à plusieurs recommandations.
## Ingestion des données historiques
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet.
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024.
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
### Architecture du flux
```text
Dataset CSV + métadonnées JSON
|
v
historical_import.py
|
+------+------+
| |
v v
Validation SHA-256
| Traçabilité
+------+------+
|
v
Normalisation
+ qualité data
|
v
Chargement par batches
|
v
PostgreSQL / TimescaleDB
| | |
v v v
dataset site reading
```
Le pipeline est développé en Python.
Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité.
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape.
Le chargement des mesures est effectué par batches de 1 000 lignes.
Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`.
### Résultats validés
Le chargement de référence a permis d'obtenir :
- 1 dataset ;
- 7 sites ;
- 122 647 mesures ;
- 0 doublon détecté dans le dataset source.
L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`.
### Évolution prévue
L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow.
L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs.
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning.
+2 -1
View File
@@ -22,6 +22,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
| Argon2id m=19456 t=2 p=1, re-hachage passif quand les paramètres changent | `app/core/hashing.py` | A02 Cryptographic Failures, A07 Identification and Authentication Failures |
| Message et temps de réponse identiques quelle que soit la cause de l'échec, haché leurre sur adresse inconnue | `app/services/auth.py` | A07, API2 |
| Limitation de débit à fenêtre glissante sur trois clés, évaluée avant le hachage | `app/services/auth.py`, `app/repositories/login_attempt.py` | A07, API4 Unrestricted Resource Consumption |
| `GET /readings` : fenêtre temporelle plafonnée à 90 jours (24h par défaut), `limit`/`offset` plafonné à 2000, refus `400` si la fenêtre est inversée ou trop large | `app/services/reading.py` | API4 |
| Absence de verrouillage de compte, qui serait un déni de service | ADR 0002 | API4 |
| Jeton de rafraîchissement opaque, haché en base, rotation avec détection de réutilisation | `app/services/auth.py`, `app/repositories/refresh_token.py` | A07, API2 |
| Séparation structurelle accès / rafraîchissement, impossible à confondre | ADR 0002 | API2 |
@@ -50,7 +51,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
| Item | État | Raison |
|---|---|---|
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. |
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |