Merge branch 'dev' into feat/openapi-contrat

Complète le contrat OpenAPI de GET /sites et GET /sites/{site_id} (merges depuis dev via #78) :
tag sites décrit, REPONSES_LECTEUR (401 + 403 mot de passe provisoire) posée au niveau du
routeur, 404 et 422 documentés sur la route detail. openapi.json régénéré.
This commit is contained in:
Johan LEROY
2026-09-16 12:04:32 +02:00
18 changed files with 664 additions and 78 deletions
+2 -2
View File
@@ -74,9 +74,9 @@ 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`. Aucune couche métier |
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, 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. Aucune table applicative |
| 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 |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
+14 -5
View File
@@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
ep["endpoints<br/>health, auth, users"]
ep["endpoints<br/>health, auth, users, sites"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log"]
md["models<br/>4 tables"]
sv["services<br/>AuthService, UserService,<br/>SiteService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"]
md["models<br/>10 tables"]
db[("PostgreSQL")]
ep --> sc
@@ -140,6 +140,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 |
| PATCH | `/api/v1/users/{id}` | Change le rôle ou l'activation. `admin` | 400, 401, 403, 404, 409, 422, 500 |
| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 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` | |
@@ -151,7 +153,14 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
donc de modifier la liste dans ce fichier de test.
Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser
pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `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. Le contrat détaillé pour le frontend est dans
[31-contrat-authentification.md](31-contrat-authentification.md).
### `/health/ready`
+44 -65
View File
@@ -4,12 +4,12 @@ PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses co
dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le
système qui en découle.
## Avertissement
## Ce que couvre ce document
**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne
contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable
n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un
relevé du code. Le modèle sera arrêté au jalon J2.
**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
d'ingestion, les agrégats continus, la compression et la rétention.
## Trois emplacements, trois rôles
@@ -35,7 +35,7 @@ Statut : `Fait`.
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
`apps/backend/tests/conftest.py`.
- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
```sql
@@ -48,16 +48,18 @@ Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap saut
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
puis `login_attempt` et `audit_log`, puis `refresh_token`.
puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée
les six tables de données décrites en fin de document et déclare l'hypertable `reading`.
## Cycle de vie d'une mesure
Statut : `Cible`. Aucun de ces maillons n'existe.
Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats
continus, ni la compression, ni la rétention ne sont écrits.
```mermaid
flowchart LR
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
ing -.-> hy[("Hypertable mesure")]
ing -.-> hy[("Hypertable reading")]
hy -.-> agg[("Agrégat continu")]
hy -.-> comp["Compression"]
hy -.-> ret["Rétention"]
@@ -133,67 +135,46 @@ donc **pas** une hypertable : une politique de rétention émettrait des `DELETE
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
piloté par l'attaquant.
## Modèle métier
Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles
s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui
évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un
`total_kwh(site_id)`.
```mermaid
erDiagram
SITE ||--o{ POINT_DE_MESURE : porte
POINT_DE_MESURE ||--o{ MESURE : produit
SITE {
int id PK
string nom
}
POINT_DE_MESURE {
int id PK
int site_id FK
string libelle
string unite
}
MESURE {
timestamptz horodatage PK
int point_id PK
double valeur
}
```
`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé
primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant
de point serait refusée.
## Gabarit de révision créant une hypertable
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision
`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
```python
def upgrade() -> None:
op.create_table(
"mesure",
sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False),
sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False),
sa.Column("valeur", sa.Float(), nullable=False),
sa.PrimaryKeyConstraint("horodatage", "point_id"),
"reading",
sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("site_id", sa.Text(), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("reading_id", "timestamp"),
)
op.execute(
"SELECT create_hypertable('reading', by_range('timestamp'), "
"create_default_indexes => FALSE)"
)
op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))")
def downgrade() -> None:
op.drop_table("mesure")
op.drop_table("reading")
```
La clé primaire inclut la colonne de temps parce que TimescaleDB l'exige : toute contrainte
unique d'une hypertable doit porter la colonne de partitionnement, et une clé sur le seul
`reading_id` serait refusée par `create_hypertable`.
`create_default_indexes => FALSE` écarte l'index que TimescaleDB pose d'office sur la seule
colonne de temps : les index déclarés dans la révision le couvrent déjà.
`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.
## Conventions
- **Noms au singulier**, en minuscules, sans préfixe de table.
- **Noms au singulier**, en minuscules, sans préfixe de table : `app_user`, `reading`.
- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau
devient ininterprétable dès le premier changement d'heure.
- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire.
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
`timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
elles ne découlent pas du schéma applicatif.
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
@@ -201,13 +182,12 @@ def downgrade() -> None:
## Questions ouvertes
Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif.
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit
porte sur son exploitation, plus sur sa forme.
- **Quelles sources de mesures**, et selon quel protocole elles sont collectées.
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
- **Quels agrégats continus**, et sur quelles fenêtres.
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
- **Quelles unités** sont manipulées, et si une même table les mélange.
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
## Modélisation détaillée des données
@@ -220,12 +200,11 @@ jusqu’aux recommandations proposées à l’utilisateur.
### Schéma de données
Le diagramme ci-dessous présente les tables et leurs relations.
Il décrit une structure de conception ; les migrations correspondantes
restent à implémenter.
La révision `e6d2026091501` les crée.
![Schéma de données EnerVision](images/EnerVision-schema-donnees.png)
*Figure — Modélisation des données EnerVision.*
*Figure : Modélisation des données EnerVision.*
### Description des tables
@@ -234,15 +213,15 @@ des données.
| Table | Rôle | Origine des informations |
|---|---|---|
| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
Les anomalies historiques décrites dans les JSON sont conservées
dans `datasets.metadata`. Elles servent à l’analyse des données
dans `dataset.metadata`. Elles servent à l’analyse des données
et ne sont pas considérées comme des alertes actuelles.
### Relations entre les tables
+4 -3
View File
@@ -8,8 +8,9 @@ de réponse honnête.
Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse,
et une section qui dit ce qui n'est pas couvert et pourquoi.
Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier
n'existent pas encore, donc plusieurs lignes resteront à compléter.
Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et
`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes
resteront à compléter une fois les endpoints d'écriture posés.
## Contrôles en place
@@ -48,7 +49,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. Un opérateur du site A pourra agir sur le site B dès que les endpoints 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. |
| **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}` répond à tout compte `lecteur` pour n'importe quel site, 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. |
| **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. |