Compare commits

...
Author SHA1 Message Date
Johan LEROY 985466e9cc ci(frontend): teste sur Node 26, comme l'image
Frontend / Audit des dépendances (push) Successful in 6s
SonarQube / test-ml (push) Failing after 5m46s
Frontend / build (push) Successful in 9m47s
SonarQube / build-front (push) Successful in 9m54s
SonarQube / build-back (push) Successful in 10m30s
Frontend / test (push) Failing after 5m3s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / test-back (push) Failing after 5m22s
SonarQube / SonarQube (push) Skipped
L'image frontend passe sur node:26-alpine3.22 ; la CI restait sur Node 24
et ne validait donc plus l'interpréteur qui construit le SPA. Angular 22.1.8
accepte >=26.0.0 ; Node 26 devient LTS le 28 octobre 2026, Node 24 passe en
maintenance le 20.
2026-09-22 08:42:46 +02:00
dependabot[bot]andJohan LEROY c347212c93 chore(deps): bump node in /apps/frontend
Bumps node from 24-alpine3.22 to 26-alpine3.22.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-alpine3.22
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 08:42:46 +02:00
Johan LEROY c67c7802c1 Merge pull request #131 from ineszang/dependabot/github_actions/actions/download-artifact-8
chore(deps): bump actions/download-artifact from 4 to 8
2026-09-22 08:42:06 +02:00
Johan LEROY e824423889 Merge pull request #128 from ineszang/dependabot/github_actions/actions/upload-artifact-7
chore(deps): bump actions/upload-artifact from 4 to 7
2026-09-22 08:42:03 +02:00
Johan LEROY 436147c8cb Merge pull request #132 from ineszang/dependabot/github_actions/actions/setup-node-7
chore(deps): bump actions/setup-node from 6 to 7
2026-09-22 08:42:00 +02:00
Johan LEROYandGitHub 8fb7ab8680 Merge pull request #130 from ineszang/dependabot/github_actions/astral-sh/setup-uv-7
chore(deps): bump astral-sh/setup-uv from 5 to 7
2026-09-22 08:40:33 +02:00
dependabot[bot]andGitHub 19f4ddc62f chore(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:24 +00:00
dependabot[bot]andGitHub 282472bae9 chore(deps): bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:23 +00:00
dependabot[bot]andGitHub 8d80cdc79d chore(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:23 +00:00
dependabot[bot]andGitHub b61287c8d2 chore(deps): bump astral-sh/setup-uv from 5 to 7
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 5 to 7.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v5...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:20 +00:00
Johan LEROYandGitHub a4be46c6e6 Merge pull request #129 from ineszang/dependabot/github_actions/actions/checkout-7
chore(deps): bump actions/checkout from 4 to 7
2026-09-22 08:36:32 +02:00
dependabot[bot]andGitHub 66a65991b5 chore(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:33:45 +00:00
Johan LEROYandGitHub 095cb4559b Merge pull request #142 from ineszang/chore/sonar-skip-dependabot
chore(ci): ignore le scan SonarQube pour les PR Dependabot
2026-09-22 08:31:48 +02:00
Johan LEROY 401d5a3174 chore(ci): ignore le scan SonarQube pour les PR Dependabot
SonarQube / build-back (push) Successful in 1m20s
SonarQube / test-ml (push) Failing after 1m11s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 59s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / SonarQube (push) Skipped
GitHub ne fournit pas les secrets du dépôt aux workflows déclenchés par
dependabot[bot] : SONAR_TOKEN arrive vide et le scan échoue sans rien
analyser, ce qui marque rouge toutes les PR de mise à jour de dépendances.
Les jobs de build et de tests du workflow restent joués sur ces PR.
2026-09-22 08:28:03 +02:00
PhyriosandGitHub cf50d3d36c Revise project milestones in README
Updated project milestones in the README to reflect new tasks and structure.
2026-09-21 14:42:41 +02:00
PhyriosandGitHub 4afcfb3ced Update project status in README.md 2026-09-21 14:28:56 +02:00
PhyriosandGitHub 1cd206adc8 Merge pull request #125 from ineszang/dev
Push dev to main
2026-09-21 14:17:01 +02:00
Dorian 3c378c177f ci(ml,etl): analyse ml/ et etl/airflow dans SonarCloud avec un rapport de couverture ML 2026-09-21 14:12:38 +02:00
Dorian 2adfdf0eb0 fix(backend): supprime les vulnerabilites Sonar du Dockerfile et allege les tests d'exception 2026-09-21 14:08:04 +02:00
PhyriosandGitHub 44f3416ffe Merge branch 'main' into dev 2026-09-21 13:48:50 +02:00
Johan LEROYandGitHub 342128ccff Merge pull request #121 from ineszang/docs/livrables-ec03-ec06
docs(architecture,ml): vue CI/CD, ML-START.md et SAST Bandit
2026-09-21 13:45:46 +02:00
Johan LEROYandClaude Opus 5 62d81e901d fix(ci,docs): lève les points de revue du SAST et de la vue CI/CD
Backend / Tests exigeant une base (push) Failing after 34s
Backend / Lint, typage et tests (push) Successful in 1m43s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Audit des dépendances (push) Successful in 57s
ML / Analyse statique de sécurité (push) Successful in 7s
ML / Lint, typage et tests (push) Successful in 3m0s
ML-START.md affirmait que l'orchestration Airflow n'existait pas : `ml_score`
tourne en `@hourly` depuis l'issue #115, seuls le mode `--csv` et un lancement
local restent manuels.

50-cicd.md : Dependabot compte six entrées sur cinq écosystèmes et non cinq
entrées, le filtre d'`airflow.yml` couvre aussi `apps/backend/` depuis le DAG
`alertes`, et les issues #21 (job de déploiement) et #22 (secrets) sont
distinguées au lieu d'être citées l'une pour l'autre. Le `continue-on-error` du
second passage Bandit est nommé pour ce qu'il est : le job reste vert même avec
un constat LOW.

Bandit est épinglé à 1.9.4 dans les deux jobs `sast` : sans épingle, une
nouvelle version passe la CI au rouge sans qu'une ligne du dépôt ait changé, et
le rejeu à l'identique documenté n'existe pas. Le `cache-dependency-glob` part :
`uvx` n'installe pas le projet, le verrou n'alimentait aucune clé de cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 13:41:17 +02:00
Johan LEROY 31886944ab Merge remote-tracking branch 'origin/dev' into docs/livrables-ec03-ec06
# Conflicts:
#	docs/architecture/00-vue-ensemble.md
#	docs/architecture/README.md
2026-09-21 13:40:44 +02:00
Johan LEROYandGitHub 63cbeafe3b Merge pull request #124 from ineszang/feat/dag-alertes
feat(etl): ordonnance la détection d'alertes et les recommandations par un DAG Airflow
2026-09-21 13:31:16 +02:00
Johan LEROY f8d08c8686 fix(etl): borne le DAG alertes sur son pire cas et couvre sa seconde commande en CI
Airflow / Lint et intégrité des DAGs (push) Successful in 56s
Airflow / Construction de l'image (push) Successful in 3m20s
`execution_timeout` plafonne une tentative, pas la tâche. Avec deux reprises, quinze
minutes par tentative autorisaient quarante-neuf minutes par tâche et quatre-vingt-dix-huit
pour l'enchaînement, quand le commentaire annonçait une somme tenant sous le pas horaire.
Le plafond passe à cinq minutes, ce qui borne le pire cas à trente-huit minutes, et le test
d'intégrité calcule désormais ce pire cas plutôt que la somme des plafonds : reprises et
délais d'attente compris, c'est la durée qu'un `max_active_runs=1` fait payer à l'exécution
suivante.

La CI vérifie aussi `app.cli generate-recommendations --help` sans réseau. C'est la seconde
commande du DAG, et son import tire FastAPI, les repositories et les services, donc une part
de l'environnement `/opt/backend` que la détection seule ne touche pas.

`10-infra.md` nomme enfin ce que le décalage de quinze minutes ne garantit pas : le plafond
de `ml_score` valant trente minutes, un scoring qui déborde prive la règle `anomaly` de la
prédiction de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre la couvre
encore.
2026-09-21 13:28:23 +02:00
Johan LEROY be44b97d7a Merge remote-tracking branch 'origin/dev' into docs/livrables-ec03-ec06
Conflit sur docs/architecture/00-vue-ensemble.md, résolu au profit de l'état réel
de dev après la #117 et la #115.

La ligne ML reprend l'orchestration Airflow de dev, que la branche avait retirée
alors que la ligne ETL de la même table la décrit ; seul le renvoi vers
ML-START.md garde la correction de chemin apportée ici.

Trois manques listés comme assumés ne le sont plus : la terminaison TLS pose HSTS
et CSP, le proxy limite le débit, et environment.ts de production est passé en URL
relative. La section plus haut les décrit déjà comme livrés.
2026-09-21 12:23:06 +02:00
Johan LEROY 306c5a52e5 Merge remote-tracking branch 'origin/dev' into feat/dag-alertes
# Conflicts:
#	.env.example
#	Makefile
#	docs/README.md
2026-09-21 12:16:58 +02:00
Johan LEROYandGitHub 801379f956 Merge pull request #117 from ineszang/feat/reverse-proxy-nginx-tls
feat(infra): reverse proxy Nginx et terminaison TLS devant la stack
2026-09-21 12:14:44 +02:00
Johan LEROY 2686880185 docs: acte l'ordonnancement des alertes par l'ADR 0008 et met à jour les vues
L'ADR 0008 décide qu'Airflow exécute le code du backend en sous-processus plutôt
que d'appeler l'API, et assume ce que cela coûte : une image plus lourde, la CI
Airflow déclenchée par les changements du backend, une clé applicative de plus.

Les vues suivent. Trois DAGs dans 10-infra.md et dans la vue d'ensemble, avec le
motif du décalage horaire. La détection n'est plus « lancée à la main » dans
20-backend.md. La génération des recommandations gagne son troisième déclencheur
dans 40-data.md. La dette de cantonnement ETL et ML porte l'aggravation comme
l'atténuation. L'affirmation selon laquelle `etl/airflow/` ne contient que des
`.gitkeep`, fausse depuis l'issue #115, disparaît.

L'index des décisions omettait les ADR 0005 et 0006, il les récupère au passage.
2026-09-21 12:14:06 +02:00
Johan LEROY ae58a896d9 feat(etl): ordonnance la détection d'alertes et les recommandations par un DAG Airflow
Le DAG `alertes` enchaîne `app.detection.internal_alerts` puis
`app.cli generate-recommendations`, à la quinzième minute de chaque heure. Le
décalage laisse finir `ml_score`, qui écrit à l'heure pile les prédictions dont
la règle `anomaly` a besoin, sans créer de dépendance entre les deux DAGs :
quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle
jamais entraîné ne doit pas priver le parc de ses alertes.

L'image Airflow porte un second environnement uv, `/opt/backend/.venv`, puisque
la logique vit dans le backend (ADR 0006) et qu'aucune route HTTP ne l'expose.
Le `UV_PROJECT_ENVIRONMENT` global hérité de l'issue #115 disparaît : il vaut
pour tous les projets, donc `uv run` depuis `/opt/ml` résolvait le venv du
backend. uv prend `<projet>/.venv` par défaut, se placer dans le dossier suffit.
La CI vérifie maintenant que les deux environnements s'importent sans réseau.

Le conteneur reçoit `DATABASE_URL` en asyncpg et une `APP_SECRET_KEY` distincte
de celle de l'API, alimentée par `AIRFLOW_APP_SECRET_KEY` : la détection ne
signe aucun jeton, et Airflow permet d'exécuter du code depuis son interface.
2026-09-21 12:13:55 +02:00
Johan LEROY 5a29faaa16 Merge remote-tracking branch 'origin/dev' into pr117-fix 2026-09-21 12:11:39 +02:00
Johan LEROY bc75528616 fix(infra): lève les points de revue du reverse proxy
Compose interpole tout le fichier avant n'importe quelle sous-commande : la garde
`${PUBLIC_HOST:?}` de l'overlay cassait `stack-down` et `stack-logs` autant que le
démarrage. La valeur retombe sur `enervision.local`, et `stack-up` vérifie à la place
que le certificat présent couvre l'hôte demandé, ce qui est la condition réelle à tenir.

La CSP `script-src 'self'` bloquait le gestionnaire `onload` que l'inlining du CSS
critique d'Angular pose sur la feuille de styles : l'application se serait affichée sans
style derrière le proxy. `inlineCritical` passe à faux, le build de production ne produit
plus aucun script en ligne.

La zone de limitation resserrée ne couvre plus que les routes qui vérifient un secret.
Derrière le NAT de l'école, où une seule adresse porte toute la promotion, `/auth/me` et
`/auth/refresh` y auraient produit des 429 en usage normal.

Enfin `certbot/certbot` est épinglé en v5.8.0 pour que Dependabot puisse le suivre, le
proxy attend une API saine plutôt que démarrée, et la redirection vers `$host` est actée
comme risque accepté : figer un nom canonique couperait l'accès par adresse IP, seule
voie ouverte sur la machine cible.
2026-09-21 12:11:32 +02:00
Johan LEROY d7457e9fd9 Merge remote-tracking branch 'origin/dev' into docs/livrables-ec03-ec06
# Conflicts:
#	docs/architecture/00-vue-ensemble.md
2026-09-21 11:56:08 +02:00
Johan LEROYandGitHub 3cd9a6b272 Merge pull request #107 from ineszang/feat/supervision-des-capteurs
feat(frontend): supervision des capteurs par site (admin)
2026-09-21 11:42:55 +02:00
Johan LEROY 26f834485c Merge branch 'dev' into feat/supervision-des-capteurs 2026-09-21 11:39:00 +02:00
Johan LEROY 777cd0ac64 fix(frontend): affiche since comme la dernière lecture reçue, pas comme un début de panne
Le schéma backend dit que since est l'horodatage de la dernière lecture du
site, identique pour tous ses capteurs en panne et sans rapport avec le début
de la panne. Le template annonçait « depuis <date> », ce que l'exploitant lit
comme une date de début de panne.

Un site sans aucune lecture renvoie ses cinq capteurs en échec avec since à
null : le template affichait « depuis » suivi d'une chaîne vide. Ce cas dit
maintenant « aucune lecture reçue ».

Format de date explicite plutôt que 'short' : aucune locale n'est enregistrée
dans app.config.ts, donc 'short' rendait la date au format en-US.
2026-09-21 11:38:52 +02:00
Johan LEROY c528ed239b Merge remote-tracking branch 'origin/dev' into feat/reverse-proxy-nginx-tls
Rapatrie la #118 (DAGs Airflow). Quatre conflits, tous additifs sauf un :

- `.env.example` et `.gitignore` : les blocs Airflow et proxy cohabitent.
- `Makefile` : `AIRFLOW` rejoint les variables de dossier, les cibles Airflow
  et TLS cohabitent dans `.PHONY`.
- `00-vue-ensemble.md` : la ligne ML de `dev` est retenue, la ligne Infra de
  cette branche aussi, chacune portant sa propre mise à jour.

L'interface Airflow rejoint la base et Mailpit sur `127.0.0.1` dans l'overlay :
elle n'a pas d'authentification à publier derrière le proxy.
2026-09-21 11:24:42 +02:00
Johan LEROY a88e51c92a Merge remote-tracking branch 'origin/dev' into feat/reverse-proxy-nginx-tls
Trois conflits, tous documentaires ou de liste :

- `.env.example` : les variables de l'API Mock et celles du proxy cohabitent.
- `Makefile` : la cible `recommendations` rejoint les cibles TLS dans `.PHONY`.
- `owasp-traceabilite.md` : la ligne API10 de `dev` est retenue, la ligne API8
  « ouvert » de `dev` est abandonnée puisque cette branche la déplace vers les
  points couverts.

Au passage, l'ADR 0006 arrivé par la #114 manquait aux deux index de décisions,
et l'ADR 0007 manquait à celui de la vue d'ensemble.
2026-09-21 11:21:45 +02:00
PhyriosandGitHub f3ea2785b3 Merge pull request #118 from ineszang/feat/dag-ml-train-score
feat(etl,ml): orchestre l'entrainement et le scoring LightGBM via deu…
2026-09-21 11:19:43 +02:00
Dorian 901ceffd72 fix(etl): fiabilise airflow-init, borne les DAGs ML et ajoute la CI Airflow
Airflow / Lint et intégrité des DAGs (push) Successful in 1m10s
Airflow / Construction de l'image (push) Successful in 1m47s
2026-09-21 11:18:02 +02:00
Johan LEROY 5545c166fd docs(architecture): ajoute la vue CI/CD et corrige trois affirmations fausses
La documentation du pipeline est explicitement notée par EC03 (C20) et n'existait
pas. L'index des vues justifiait son absence par un manque de matière : quatre
workflows et quatorze jobs en sont assez.

Trois affirmations de 00-vue-ensemble.md étaient devenues fausses, ce qui coûte
plus cher qu'une absence puisqu'on les lit et qu'on construit dessus :

- la CI/CD y était déclarée `Cible` / `Rien` alors que quatre workflows tournent ;
- le flux bout en bout y était `Cible` avec "aucun maillon n'existe, à l'exception
  de la base", alors que tout le chemin de lecture et deux ingestions existent ;
- l'analyse de dépendances y était listée comme absente alors que pip-audit,
  npm audit et Dependabot sont en place. Seule celle des images manque.

Ferme C20 de la grille d'auto-évaluation.
2026-09-21 10:50:25 +02:00
Johan LEROY f0ad8e9990 ci(security): branche Bandit sur apps/backend et sur le module ML
Le pipeline auditait les dépendances (pip-audit, npm audit, Dependabot) mais
jamais le code lui-même : aucun SAST, aucun DAST. C'était le seul rouge de BC03
qui se fermait en une étape de workflow.

Le job bloque à partir de MEDIUM/MEDIUM, et une seconde passe sans seuil publie
les constats LOW sans bloquer : sans elle, un LOW disparaîtrait du journal sans
trace. Le périmètre est le code livré (`app`, `enervision_ml`) et non les
tests, qui emploient légitimement des secrets factices et des `assert`.

Relevé au 21/09 : zéro constat tous niveaux confondus sur 5 904 lignes.

Couvre #39. Ferme C18 de la grille d'auto-évaluation.
2026-09-21 10:50:25 +02:00
Johan LEROY 3c01ab3ecc docs(ml): écrit ML-START.md et répare les renvois cassés
Le document était référencé 11 fois, dont 4 depuis le code (config.py, data.py,
train.py, score.py, features.py), et n'avait jamais été écrit. Deux chemins
contradictoires coexistaient : `../ML-START.md` depuis ml/README.md et
docs/architecture/, `docs/ML-START.md` depuis le code. Le chemin retenu est celui
du code, majoritaire et le seul qu'un lecteur du module rencontre.

Il couvre les trois sections que les renvois annoncent : mécanisme d'accès aux
données et pourquoi ce n'est pas l'API, étapes d'un run de scoring, frontière
entre FastAPI et LightGBM.

Ferme C34 de la grille d'auto-évaluation.
2026-09-21 10:50:12 +02:00
Dorian 6f6f451eb4 Merge remote-tracking branch 'origin/dev' into feat/dag-ml-train-score 2026-09-21 10:39:07 +02:00
Johan LEROYandGitHub cc3e38efa3 Merge pull request #112 from ineszang/feat/mock-api-import
Import des données depuis l'API Mock
2026-09-21 10:29:36 +02:00
Johan LEROY 0a2ed5ad8f docs: distingue l'ingestion des mesures de celle des alertes
La #114 arrive sur dev avec un ADR 0006 qui note que l'ingestion de
l'API Mock /alerts reste à faire. « Les deux sources sont implémentées »
se lisait comme couvrant aussi les alertes.
2026-09-21 10:25:48 +02:00
Johan LEROY 459ddf1792 Merge remote-tracking branch 'origin/dev' into feat/mock-api-import 2026-09-21 10:24:48 +02:00
Johan LEROY de697b080d docs: rétablit la hiérarchie des titres et les motifs du document Data
40-data.md était passé à quatre titres de niveau 1 et etl/README.md à cinq,
alors que les huit autres documents d'architecture n'en ont qu'un. Les
sections ajoutées redescendent d'un niveau.

La réécriture de la section « Tables d'authentification » avait aussi vidé
quatre choix de modélisation de leur raison, dont le renvoi à l'ADR 0004 sur
audit_log.actor_id. Ces motifs sont rétablis, et les deux tables de
réinitialisation reçoivent le leur.

Documente enfin la frontière de confiance avec l'API Mock : les quatre
garde-fous, les plages de PHYSICAL_BOUNDS, et ce qu'il reste à faire.
2026-09-21 10:21:18 +02:00
Johan LEROY f238940867 fix(etl): borne la réponse de l'API Mock avant écriture en base
L'API Mock est le seul item OWASP API10 du projet, et ce script en est le
premier consommateur. Des quatre garde-fous exigés par la traçabilité OWASP,
seul le timeout était en place.

- plafonne la taille des réponses : MAX_SITES sites, au plus --limit mesures ;
- borne chaque grandeur physique par PHYSICAL_BOUNDS, une valeur hors plage,
  d'un type inattendu, NaN ou infinie devenant NULL avec sa raison dans
  null_reasons et data_quality à degraded ;
- ne recopie vers la base que les champs attendus, via build_site_row() et
  build_reading_row(), au lieu de passer les dictionnaires de l'API en
  paramètres SQL ;
- écarte une data_quality que ck_reading_quality refuserait, plutôt que de
  faire échouer le lot entier ;
- nomme la cible du ON CONFLICT, qui avalait jusqu'ici toute violation
  d'unicité, y compris celle de la clé primaire.

raw_data conserve la réponse d'origine intacte : rien n'est perdu, seule son
exploitation est bornée.
2026-09-21 10:21:10 +02:00
Dorian b941880c22 feat(etl,ml): orchestre l'entrainement et le scoring LightGBM via deux DAGs Airflow 2026-09-21 10:03:23 +02:00
Johan LEROYandGitHub 9d2384a639 Merge pull request #114 from ineszang/feat/moteur-regles-recommandations
feat(backend): moteur de règles de recommandations et route de génération
2026-09-21 09:51:34 +02:00
Johan LEROY 0c487fa7be docs: acte la terminaison TLS par l'ADR 0007 et met à jour les vues
L'ADR 0007 tranche le reverse proxy en Compose plutôt que l'ingress k3s, qui
supposait un registre et des manifestes inexistants, et referme la première
question ouverte de 10-infra.md.

Les vues suivent : troisième topologie et ports 80/443 dans 10-infra.md, TLS,
HSTS et CSP passent d'« Absent, et assumé » à « En place » dans la vue
d'ensemble, la ligne API8 transport rejoint les points couverts de la
traçabilité OWASP.

Trois affirmations périmées disparaissent au passage : le compose a bien un
service frontend, environment.ts ne pointe plus sur localhost:8000, et le
Dockerfile du front n'est plus mono-étage sur une branche.
2026-09-21 09:51:19 +02:00
Johan LEROY b3efb98208 feat(infra): reverse proxy Nginx et terminaison TLS devant la stack
Le SPA appelle /api/v1 en relatif et rien ne routait cet appel vers l'API
une fois en conteneur. Le cookie de rafraîchissement prend le préfixe
__Secure- dès que APP_ENV sort de local, donc sans HTTPS il n'était jamais
posé et l'authentification ne survivait pas à un rechargement de page.

Un service proxy, image officielle nginx dont la configuration est montée en
volume, devient le seul composant publié : 80 redirige vers 443 et sert le
défi ACME, 443 termine le TLS, sert le SPA sur / et l'API sur /api/ sous la
même origine, pose HSTS et CSP que l'application refuse délibérément de
poser, et ajoute une limitation de débit au frontal. Backend et frontend ne
sont plus publiés, la base et l'interface Mailpit sont ramenées sur la
boucle locale.

nginx lit toujours les deux mêmes fichiers de certificat : seule leur
fabrication varie, script openssl pour la démonstration, deploy-hook certbot
le jour où un domaine public existera. Le chemin ACME est livré et
documenté, pas exercé : sur une IP privée le défi HTTP-01 ne peut pas
aboutir.
2026-09-21 09:51:09 +02:00
Johan LEROY 2d7b4bd74d fix: publie le service frontend sur 3000, le port qu'écoute son nginx
Le compose mappait vers le port 80 du conteneur alors que le nginx de
l'image écoute sur 3000 (apps/frontend/nginx.conf, EXPOSE 3000). Le port
publié ne pointait sur rien, le service frontend ne répondait pas.
2026-09-21 09:51:09 +02:00
Meryemel-gham 56c6b79a5a style(etl): applique le formatage Ruff aux tests API Mock
Backend / Lint, typage et tests (push) Successful in 1m57s
Backend / Tests exigeant une base (push) Failing after 36s
Backend / Audit des dépendances (push) Successful in 57s
SonarQube / build-back (push) Successful in 1m30s
SonarQube / build-front (push) Successful in 9m37s
SonarQube / test-back (push) Failing after 53s
SonarQube / test-front (push) Failing after 5m5s
SonarQube / SonarQube (push) Skipped
2026-09-21 09:47:07 +02:00
Johan LEROY 19c38fe571 fix(backend): decoupe l'insertion des recommandations en lots et remet les docs a jour
Backend / Tests exigeant une base (push) Failing after 34s
Backend / Lint, typage et tests (push) Successful in 1m24s
Backend / Audit des dépendances (push) Successful in 57s
SonarQube / build-back (push) Successful in 1m5s
SonarQube / build-front (push) Successful in 9m39s
SonarQube / test-back (push) Failing after 51s
SonarQube / test-front (push) Failing after 5m6s
SonarQube / SonarQube (push) Skipped
`create_missing()` construisait un seul `INSERT ... VALUES` pour la totalite des
propositions. Avec quatre colonnes par ligne et le plafond asyncpg de 32 767
parametres, la route echouait au-dela de 8 191 recommandations par appel, cas
devenu realiste maintenant que la detection interne (#104) alimente `alert` en
continu. L'insertion passe par des lots de `TAILLE_DE_LOT` lignes, sur le patron
de `app/etl/historical_import.py`.

L'ADR 0006, `20-backend.md` et la description de la PR annoncaient qu'aucune
source n'alimentait `alert` et que #104 n'etait pas commencee. #104 est livree
sur `dev` depuis la #113 : les phrases sont corrigees plutot que laissees a
vieillir dans un ADR.
2026-09-21 09:45:25 +02:00
Johan LEROY 9a1af94d88 Merge remote-tracking branch 'origin/dev' into feat/moteur-regles-recommandations 2026-09-21 09:40:46 +02:00
Meryemel-gham 452cfdef85 test(etl): corrige les points bloquants de la revue API Mock 2026-09-21 09:36:10 +02:00
Meryemel-gham 96dd1f834c docs: documente l'ingestion depuis l'API Mock 2026-09-21 09:11:44 +02:00
Meryemel-gham e66ef86729 test(apps): couvre l'import depuis l'API Mock 2026-09-21 09:11:43 +02:00
Meryemel-gham 0318ee6cc5 feat(apps): ajoute l'import depuis l'API Mock 2026-09-21 09:11:43 +02:00
Meryemel-gham 0ddfb1997d feat(apps): configure la connexion à l'API Mock 2026-09-21 09:11:43 +02:00
PhyriosandGitHub b96546cea3 Merge pull request #113 from ineszang/feat/detection-alertes-internes
feat(backend): detecte les alertes internes a partir des lectures et …
2026-09-18 17:00:48 +02:00
Dorian c059f838bb fix(backend): fiabilise le tri des lectures/predictions et la detection de redemarrage a zero
Backend / Tests exigeant une base (push) Failing after 38s
Backend / Lint, typage et tests (push) Successful in 1m29s
Backend / Audit des dépendances (push) Successful in 1m3s
SonarQube / build-back (push) Successful in 1m8s
SonarQube / build-front (push) Successful in 9m36s
SonarQube / test-back (push) Failing after 1m5s
SonarQube / test-front (push) Failing after 5m15s
SonarQube / SonarQube (push) Skipped
2026-09-18 16:58:03 +02:00
ValentinDeFariaandGitHub f9c2a4610c Update dashboard.ts
Frontend / Audit des dépendances (push) Successful in 6s
SonarQube / build-back (push) Successful in 1m6s
Frontend / build (push) Successful in 9m52s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 52s
Frontend / test (push) Failing after 5m0s
SonarQube / test-front (push) Failing after 5m2s
SonarQube / SonarQube (push) Skipped
2026-09-18 16:56:59 +02:00
ValentinDeFariaandGitHub b5fa7b0010 Merge branch 'dev' into feat/supervision-des-capteurs 2026-09-18 16:54:39 +02:00
Dorian a9e124a97d feat(backend): detecte les alertes internes a partir des lectures et previsions 2026-09-18 16:10:06 +02:00
Johan LEROY edd5e82d29 fix 2026-09-18 16:04:10 +02:00
Johan LEROY aeb07e14db feat(backend): moteur de règles de recommandations et route de génération
`recommendation` n'avait aucun écrivain : les quatre couches de lecture étaient
livrées, mais rien ne produisait de ligne. Le moteur comble ce trou.

Le catalogue `REGLES` vit dans `app/services/`, pas dans `ml/` : il lit `alert.type`,
`alert.severity`, `alert.value` et `alert.threshold`, sans modèle ni feature, et
s'appuie sur deux repositories existants. L'arbitrage avec l'ADR 0005, qui annonçait
#38 du côté ML, est tranché par l'ADR 0006.

Sept règles, cinq par type d'alerte et deux transverses (sévérité critique,
dépassement d'au moins 20 % du seuil), donc une à trois recommandations par alerte.
L'idempotence est portée par la base : `create_missing()` insère en
`ON CONFLICT DO NOTHING` sur `uq_recommendation_alert_rule`, ce qui supprime la
fenêtre entre un contrôle préalable et l'insertion. `rule_reference` devient de ce
fait une clé fonctionnelle, d'où le suffixe de version sur chaque référence.

Deux déclencheurs : `POST /api/v1/recommendations/generate` réservé `admin`, et
`python -m app.cli generate-recommendations` (cible `make recommendations`).

Limite connue : aucune source n'alimente `alert` aujourd'hui, ni détection interne
(#104) ni ingestion de l'API Mock. La route répond, le rapport reste à zéro, et la
chaîne s'allume sans retoucher le moteur le jour où les alertes existent.

Tests : 80 unitaires et API verts, plus 6 d'intégration dont l'idempotence jouée
contre PostgreSQL.

Closes #38
2026-09-18 15:49:54 +02:00
Johan LEROYandGitHub 619024f547 Merge pull request #105 from ineszang/feat/service-de-scoring
feat(ml,backend): implemente le service de scoring et GET /predictions
2026-09-18 15:33:12 +02:00
Dorian 460d6c1b3e Merge remote-tracking branch 'origin/dev' into feat/service-de-scoring 2026-09-18 15:06:41 +02:00
Dorian eb4291b10a fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux 2026-09-18 14:58:39 +02:00
Johan LEROYandGitHub 9a72bb3a8f Merge pull request #108 from ineszang/test/matrice-acces-roles
test(backend): matrice d'accès par rôle et CI d'intégration
2026-09-18 14:25:40 +02:00
Johan LEROY bd1da05587 Merge remote-tracking branch 'origin/dev' into test/matrice-acces-roles
Conflit sur .github/workflows/backend.yml : dev y a ajouté le job
`security-audit` (PR #100) pendant que cette branche y ajoutait le job
`integration`. Les deux jobs sont conservés côte à côte.
2026-09-18 14:22:10 +02:00
Johan LEROYandGitHub 9376420868 Merge pull request #103 from ineszang/feat/site-detail-view
feat(frontend): vue détail d'un site
2026-09-18 14:14:55 +02:00
Johan LEROY d535959fbe Merge remote-tracking branch 'origin/dev' into feat/site-detail-view 2026-09-18 14:11:48 +02:00
Johan LEROYandClaude Opus 5 33807e3038 fix(frontend): traite la revue de phyri0s sur la vue détail d'un site
Quatre points portant sur le code de cette PR :

- L'échec de chargement laissait à l'écran le site précédemment affiché sous le
  bandeau d'erreur : `reportUnavailable()` vide désormais site, mesure et
  historique, pour qu'on ne lise pas les chiffres de A en croyant regarder B.
- L'historique était tracé à rebours : l'API trie en timestamp décroissant
  (`ReadingRepository.list_history`), le graphique rétablit la chronologie.
- Une consommation `null` (panne capteur) alimentait la jauge avec un 0,
  indiscernable d'un site qui ne consomme rien : la jauge n'est plus montée dans
  ce cas, la raison de l'absence est affichée à la place. Une consommation
  réellement mesurée à 0 continue d'afficher la jauge.
- `getSite` et `getCurrent` ne dépendent pas l'un de l'autre : `forkJoin` économise
  un aller-retour en série à chaque ouverture de la page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:11:44 +02:00
ineszangandGitHub c914d41f33 Merge pull request #110 from ineszang/feat/sonar-dashboard
feat: sonar -> analyse des TU dans le backend
2026-09-18 13:59:33 +02:00
ineszang44 d3047f53e8 Merge branch 'feat/sonar-dashboard' of https://github.com/ineszang/ProjetPiscine_EnerVision into feat/sonar-dashboard 2026-09-18 13:56:06 +02:00
ineszang44 ec63798807 fix: lcov -> xml pour le rapport de couverture 2026-09-18 13:55:46 +02:00
ineszangandGitHub 1e168ef03e Merge branch 'dev' into feat/sonar-dashboard 2026-09-18 13:48:18 +02:00
ineszang44 ddf7e17788 test: properties sonar 2026-09-18 13:39:51 +02:00
ineszang44 56e8f95729 test: jobs de tests et de build de sonarqube 2026-09-18 13:22:31 +02:00
ineszang44 91d435748c test: jobs de tests et de build de sonarqube 2026-09-18 13:17:31 +02:00
ineszang44 0b41580310 test: jobs de tests et de build de sonarqube 2026-09-18 13:14:50 +02:00
ineszang44 e3d436ea53 test: jobs de tests et de build de sonarqube 2026-09-18 13:13:23 +02:00
Johan LEROYandGitHub c453700b13 Merge pull request #101 from ineszang/chore/coverage-tests-frontend-ci
chore(ci): ajoute la couverture de tests au job test du frontend
2026-09-18 12:38:15 +02:00
ineszang44 f427f8a8f3 chore+feat: allègement du workflow front, workflow pour sonarqube 2026-09-18 12:32:18 +02:00
Johan LEROY 7be8a44e89 Merge branch 'dev' into chore/coverage-tests-frontend-ci
Résolution du conflit sur .github/workflows/frontend.yml :
- conserve les étapes upload/download de l'artifact lcov ajoutées sur dev,
  sans lesquelles le rapport ne quitte pas le runner du job test et
  n'atteint jamais le scanner Sonar ;
- conserve le job security-audit et le retrait du bloc deploy commenté ;
- retient la commande de test de la branche.

Le séparateur `--` manquait côté dev : npm consommait les trois flags
comme sa propre configuration et ng test tournait sans argument. De plus
`--code-coverage` n'existe pas dans le builder @angular/build:unit-test,
où l'option s'appelle `coverage`.
2026-09-18 12:30:10 +02:00
Johan LEROYandGitHub 2095ad6bd3 Merge pull request #100 from ineszang/chore/audit-dependances-ci
chore(ci): ajoute un audit de sécurité des dépendances (npm audit, pi…
2026-09-18 12:26:46 +02:00
Johan LEROY fb06bf0062 chore(ci): isole l'audit de sécurité et le fait porter sur le verrou
Backend / Audit des dépendances (push) Successful in 1m30s
Frontend / Audit des dépendances (push) Successful in 7s
Backend / Lint, typage et tests (push) Successful in 4m15s
Frontend / build (push) Successful in 11m3s
Frontend / test (push) Failing after 5m7s
Frontend / SonarQube (push) Skipped
L'audit backend était la dernière étape du job de vérification : un lint
ou un test en échec suffisait à le sauter, et `pip-audit` sans argument
auditait l'environnement courant, donc aussi les 28 paquets injectés par
son propre `--with`. Il audite maintenant l'export du verrou, dans un job
dédié, en symétrie avec le frontend.

Côté frontend, `npm audit` lit le verrou et n'a besoin ni de `npm ci` ni
du job `build`. Le workflow déclare enfin ses permissions, comme
backend.yml et ml.yml.
2026-09-18 12:23:50 +02:00
Johan LEROY e27142c7db Merge branch 'dev' into chore/audit-dependances-ci 2026-09-18 12:23:25 +02:00
ineszang44 77feabcbad Merge branch 'feat/sonar-dashboard' of https://github.com/ineszang/ProjetPiscine_EnerVision into feat/sonar-dashboard 2026-09-18 12:22:41 +02:00
ineszang44 18a4be6e38 feat+rollback: job de test sur le backend dans le workflow du front pour sonar, properties de sonar dans la racine du projet 2026-09-18 12:17:43 +02:00
Johan LEROY feee6c3ffc docs(backend): documente la classification des routes et la CI d'intégration
La checklist « ajouter une route métier » demandait de maintenir deux listes à la main en
prévenant qu'une route oubliée n'y serait pas détectée. Elle pointe désormais vers
`tests/api/acces.py`, où l'oubli échoue.

Corrige au passage « quatre routes seulement sont publiques » : il y en a sept dans le
contrat, les deux sondes, `/auth/login`, `/auth/logout`, `/auth/forgot-password` et les deux
routes de réinitialisation, qui portent leur autorisation dans le jeton à usage unique plutôt
que dans un `Principal`.

`TESTING.md` précise que les tests `integration` ne sont plus facultatifs : ils cassent la CI
comme les autres.
2026-09-18 12:09:12 +02:00
Johan LEROY 173f91f26f ci(backend): joue les tests d'intégration sur un service TimescaleDB
`pyproject.toml` écarte le marqueur `integration` par défaut, et aucun workflow ne montait de
base : 97 tests, dont les neuf fichiers de dépôts et le schéma de données, n'avaient jamais
été joués ailleurs que sur un poste. La condition avait été déléguée à #20, fermée le 17/09
sans l'avoir livrée.

Le job monte l'image de `docker-compose.yml` et non une image `postgres` nue : la première
migration refuse de s'appliquer sans l'extension TimescaleDB, et un écart d'image rendrait ce
job vert sur une base qui n'est pas la nôtre. `db/init/110-test-database.sql` n'étant pas
monté ici, l'extension est créée en une étape avant `alembic upgrade head`.

Le job `verification` est inchangé : il reste jouable sans Docker, avec son seuil de
couverture de 85 %.
2026-09-18 12:09:12 +02:00
Johan LEROY 1cd3688256 test(backend): croise chaque route gardée avec les trois rôles
Le dépôt vérifiait le refus d'un lecteur sur les cinq routes `admin`, et rien de plus. Les
huit routes `lecteur` n'étaient jouées qu'avec un lecteur : une garde posée trop haut, par
exemple `AdminDep` sur `/sites`, n'aurait fait échouer aucun test.

La matrice couvre les deux sens. Un rôle insuffisant reçoit un 403 `Droits insuffisants`,
un rôle suffisant ne le reçoit jamais. L'assertion porte sur le refus de la garde et pas sur
un 200, sans quoi elle dépendrait du contenu de la base : un 404 ou un 422 est une réponse
acceptable, un 403 non.

Sous le marqueur `integration`, la même matrice est rejouée avec de vrais jetons obtenus par
`/auth/login`, donc en traversant le décodage du JWT et la relecture du compte en base que
`dependency_overrides` court-circuite. Deux invariants y sont figés : `operateur` n'ouvre
aujourd'hui aucune route de plus que `lecteur`, faute d'écriture métier dans l'API, et
`/auth/logout-all` échappe au refus `password_change_required` parce qu'elle prend un
`CurrentPrincipalDep` nu. Le second est signalé, pas corrigé.

Closes #61
2026-09-18 12:08:59 +02:00
Johan LEROY 7eef960a30 test(backend): classe les routes du contrat et dérive les listes d'autorisation
`ROUTES_A_ROLE` était recopiée dans `test_openapi.py`, et deux de ses entrées portaient
`{id}` là où le contrat expose `{user_id}`. Elles ne correspondaient donc à aucune
opération, et `test_every_role_guarded_route_documents_the_role_refusal` passait au vert
sans rien vérifier sur `PATCH /users/{user_id}` ni sur sa réinitialisation de mot de passe :
11 des 13 routes gardées étaient réellement couvertes.

`tests/api/acces.py` porte désormais la classification des 24 routes du contrat en quatre
ensembles, dont la table `ROLE_MINIMUM`, et `test_every_declared_route_is_classified` refuse
aussi bien une route non classée qu'une entrée qui ne correspond plus à rien. C'est ce que
`docs/architecture/20-backend.md` annonçait comme impossible : « ces deux listes sont
maintenues à la main, pas dérivées ».

Au passage, `chemin_concret()` substitue les trois gabarits du contrat et non plus le seul
`{user_id}`, ce qui est sans effet sur le refus anonyme mais nécessaire à un appel qui doit
aboutir.
2026-09-18 12:08:59 +02:00
Dorian db81290026 Merge remote-tracking branch 'origin/dev' into feat/service-de-scoring 2026-09-18 12:05:38 +02:00
Valentin 7f710c9084 feat(frontend): supervision des capteurs par site (admin) 2026-09-18 12:02:48 +02:00
ineszang44 12fb8860d1 chore: fichier de configuration pour chaque module (front et back) 2026-09-18 11:54:21 +02:00
Dorian d9229e5a93 feat(frontend): affiche les prévisions de consommation sur le dashboard 2026-09-18 11:51:02 +02:00
ineszang44 7c2936f2ef chore: fichier de configuration pour chaque module (front et back) 2026-09-18 11:26:45 +02:00
Johan LEROY 9d3e402ca4 test(frontend): couvre la vue détail sur le contrat SiteCurrentResponse
Le cas « site sans mesure » ne passe plus par une réponse nulle mais par
`timestamp` à null : il ne doit alors pas interroger `/readings`, et la vue
annonce l'absence de mesure au lieu de six métriques de cause inconnue.
2026-09-18 11:11:12 +02:00
Dorian e9376a98bf feat(ml,backend): implemente le service de scoring et GET /predictions (#37) 2026-09-18 11:06:04 +02:00
Johan LEROY 7db57f162c refactor(frontend): aligne la vue détail sur le contrat SiteCurrentResponse
`/sites/{site_id}/current` renvoie un objet toujours présent, sans `reading_id`
ni `source`, avec `timestamp` nullable et `null_reasons` non nullable. La vue
s'appuyait sur une réponse nulle pour détecter l'absence de mesure : elle
s'appuie désormais sur `timestamp`, et affiche `data_quality`, que le contrat
précédent ne portait pas.

`getCurrent()` rejoint `SitesService`, l'endpoint appartenant à `/sites`.
2026-09-18 11:03:57 +02:00
Johan LEROY 9ccc603d88 Merge branch 'dev' into feat/site-detail-view
Le backend de la branche réimplémentait `/sites/{site_id}/current` en renvoyant
`ReadingResponse | None`. La PR #84, mergée entre-temps, l'expose via
`SiteCurrentResponse`. Les cinq conflits sont donc tranchés en faveur de `dev`,
et tout `apps/backend/` est repris à l'identique : la branche redevient
purement frontend.
2026-09-18 11:01:45 +02:00
Johan LEROYandGitHub 297d85a0ca Merge pull request #84 from ineszang/feat/endpoint-sites-current
feat(backend): expose GET /api/v1/sites/{site_id}/current
2026-09-18 10:33:53 +02:00
ineszangandGitHub af58172742 Merge pull request #99 from ineszang/feat/sonar-dashboard
test: ajout de propriétés dans le fichier de config pour sonarqube
2026-09-18 10:31:19 +02:00
Johan LEROY b433e01fa8 fix(backend): départage aussi les égalités de timestamp dans latest_by_site
Backend / Lint, typage et tests (push) Successful in 1m18s
`latest_by_site` portait le même défaut que `latest_for_site` : `DISTINCT ON (site_id)`
ordonné sur `site_id, timestamp DESC` sans départage, alors que `uq_reading_source`
autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère.
`/stats/summary` pouvait donc afficher une consommation différente d'un appel à
l'autre pour un site alimenté par un backfill CSV et une écriture live.

Test `integration` dédié, qui échoue sans le correctif.
2026-09-18 10:28:04 +02:00
Johan LEROY 5eb74aa64a fix(backend): traite la revue de phyri0s sur la PR #84
Tri non déterministe : `latest_for_site` départage désormais les égalités de
timestamp par `reading_id` décroissant, comme `list_history`. `uq_reading_source`
autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère, donc
le `LIMIT 1` pouvait renvoyer l'une ou l'autre d'un appel à l'autre.

Tests : trois tests `integration` sur `latest_for_site` (plus récente, égalité de
timestamp, isolation par site). Le test d'égalité échoue sans le correctif ci-dessus.

Duplication : `DataQuality` et le repli vers `critical` sortent dans
`app/services/data_quality.py`, partagé par `stats.py`, `site.py` et `sensor.py`,
qui en portaient trois copies indépendantes. Supprime au passage deux
`# type: ignore[assignment]`.
2026-09-18 10:28:04 +02:00
ineszang44 cc0a58ac4c test: sonarqube 2026-09-18 10:23:09 +02:00
ineszang44 2400b6f05e test: sonarqube 2026-09-18 10:21:56 +02:00
ineszang44 9e33c276d6 test: sonarqube 2026-09-18 10:16:31 +02:00
ineszang44 6cb9ac00cb test: sonarqube 2026-09-18 10:14:06 +02:00
ineszang44 c1f63889c1 test: sonarqube 2026-09-18 10:10:05 +02:00
ineszang44 5875e8c239 test: sonarqube 2026-09-18 10:01:28 +02:00
ineszang44 c8383014a8 Merge branch 'dev' into feat/sonar-dashboard 2026-09-18 09:52:27 +02:00
PhyriosandGitHub 3cf9194d4c Add progress update for project on 2026-09-18
Document progress update for the project as of September 18, 2026, detailing closed issues, merged PRs, and individual contributions.
2026-09-18 09:24:26 +02:00
PhyriosandGitHub 8def1e23af Add progress report for 15/09/2026 review meeting 2026-09-18 09:23:41 +02:00
Johan LEROY e26948932e fix(frontend): annule le chargement en cours lors d'un changement rapide de site
Sans switchMap sur le flux externe, une réponse HTTP en retard pouvait écraser
l'affichage du site actuellement sélectionné après une navigation rapide entre
deux sites.
2026-09-17 16:47:40 +02:00
Johan LEROYandClaude Sonnet 5 41acdb54b6 fix(frontend): désactive les fixtures mock, le backend est alimenté
Le dataset historique 2023-2024 est chargé en base. /stats/summary et
/alerts servaient encore des fixtures déconnectées de ces données réelles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012i5NteMLRZgfTAKB5GD37X
2026-09-17 16:43:40 +02:00
Johan LEROYandClaude Sonnet 5 5d921a9b1e feat: vue détail d'un site (#51)
Ajoute la page de détail d'un site (fiche, mesure instantanée, jauge de
consommation, historique) en remplacement du placeholder. Les champs
null sont affichés explicitement avec leur raison plutôt que masqués.

Ajoute l'endpoint GET /sites/{id}/current côté backend, qui retourne la
dernière mesure connue d'un site sans filtre temporel, conformément au
contrat de l'issue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012i5NteMLRZgfTAKB5GD37X
2026-09-17 16:43:15 +02:00
Johan LEROYandGitHub 56c134beb0 Merge pull request #95 from ineszang/feat/site-list-view
feat(frontend): vue liste des sites
2026-09-17 16:04:09 +02:00
Johan LEROY 8fb5ab9f65 fix(frontend): traite la revue de phyri0s sur la PR #95
Frontend / build (push) Successful in 9m42s
Frontend / test (push) Failing after 5m23s
Frontend / SonarQube (push) Skipped
- Reutilise .ev-link pour le lien "Detail" de la liste des sites au
  lieu de dupliquer ses regles de style.
- site.location vide est traite comme absent (affiche "-"), pas
  seulement null/undefined.
- siteId de la page detail suit desormais route.paramMap de facon
  reactive plutot qu'une lecture ponctuelle du snapshot, pour rester
  a jour quand Angular reutilise l'instance du composant en changeant
  de site.
- Ajoute provideRouter([]) manquant dans un test dashboard existant,
  necessaire depuis l'ajout du lien "Voir les sites" au rebase sur dev.
2026-09-17 16:01:15 +02:00
Johan LEROY e22feac2c4 fix(frontend): navigation cohérente entre les pages authentifiées
Logo cliquable vers le tableau de bord (ev-brand-link) et fil d'Ariane
(ev-breadcrumb) sur les sous-pages, pour éviter les impasses de
navigation entre dashboard, liste des sites et détail de site.
2026-09-17 15:58:50 +02:00
Johan LEROY 5581cb1ef3 feat(frontend): vue liste des sites
Nouveau SitesService (GET /sites) et page SiteList consommant le design
système (ev-card, ev-badge, ev-alert, ev-brand). Ajoute la route /sites,
un lien depuis le dashboard, et une route détail /sites/:siteId pointant
vers un placeholder minimal en attendant l'issue #51.

Closes #49
2026-09-17 15:58:35 +02:00
Johan LEROYandGitHub 1d8c986386 Merge pull request #93 from ineszang/feat/design-system
feat(frontend): design système - tokens, composants ui et restylage des pages
2026-09-17 15:56:24 +02:00
Johan LEROY 85cb7c9eeb fix(frontend,backend): traite la revue de phyri0s sur la PR #93
Backend / Lint, typage et tests (push) Successful in 1m23s
Frontend / build (push) Successful in 9m38s
Frontend / test (push) Failing after 5m4s
Frontend / SonarQube (push) Skipped
Corrige les 10 points de la revue du systeme de design : garde-fou de
route explicite pour /docs, /redoc et /static, ton distinct pour les
alertes critical vs high, flex-shrink sur le bon element du badge,
mutualisation du bloc ev-card dans _auth-page.scss, bouton de
deconnexion migre vers ev-button (nouvel input fullWidth), tokens
manquants (--color-danger-hover, --color-warning-text,
--color-text-inverse, --color-critical), test de synchronisation des
deux copies du logo, openapi_avec_logo qui enveloppe application.openapi
au lieu de le reimplementer, doc du frontend et index mis a jour, et
suppression du CSS mort .form-error.
2026-09-17 15:53:55 +02:00
Johan LEROY 39b1d28ead Merge remote-tracking branch 'origin/dev' into feat/design-system
# Conflicts:
#	apps/frontend/src/app/features/auth/change-password/change-password.html
#	apps/frontend/src/app/features/auth/change-password/change-password.ts
#	apps/frontend/src/app/features/auth/login/login.html
#	apps/frontend/src/app/features/auth/login/login.ts
2026-09-17 15:44:10 +02:00
Johan LEROYandGitHub 5394257855 Merge pull request #90 from ineszang/feat/password-policy-forgot-password
feat(auth): politique de complexité du mot de passe et flux de réinitialisation
2026-09-17 15:34:26 +02:00
Johan LEROY c741ffc827 fix(auth): corrige la CI cassee par le nouvel endpoint de validation
Backend / Lint, typage et tests (push) Successful in 1m19s
/auth/reset-password/validate manquait a la liste explicite des routes
publiques (test_route_protection) et n'avait pas le modele de reponse
422 declare (openapi.json desynchronise du contrat genere).
2026-09-17 15:31:14 +02:00
Johan LEROY d7f775f9f7 feat(auth): verifie le lien de reset des le chargement, sans le consommer
Ajoute GET /auth/reset-password/validate (lecture seule, sans rate
limit : le jeton est un secret de 256 bits non brute-forcable) pour que
la page reset-password redirige immediatement vers /login si le lien
est invalide ou expire, plutot que d'attendre la soumission du
formulaire. La verification a la soumission (confirm_password_reset)
reste la seule source de verite atomique.
2026-09-17 15:28:50 +02:00
Johan LEROY e381e0de09 feat(frontend): checklist de complexite du mot de passe sur reset-password
Remplace l'indice statique sous le champ nouveau mot de passe par une
checklist qui coche chaque regle (longueur, majuscule, minuscule,
chiffre, caractere special) au fur et a mesure de la saisie. Les regles
individuelles (PASSWORD_REQUIREMENTS) sont exposees depuis le meme
validateur que PASSWORD_PATTERN pour rester la seule source de verite.
2026-09-17 14:57:05 +02:00
Johan LEROY 7674955637 fix(frontend): un lien de reset absent ou expire renvoie vers login avec un message standard
Avant, un token absent affichait un message inline sur /reset-password, et
un token invalide/expire ne se voyait qu'apres soumission du formulaire.
Les deux cas redirigent maintenant vers /login avec le motif
"lien-expire", qui y affiche le message standard "Ce lien de
reinitialisation est invalide ou a expire. Connectez-vous ou
redemandez-en un."
2026-09-17 14:49:46 +02:00
ineszang44 19cfac1cff fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:48:52 +02:00
ineszang44 1fce577a78 fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:45:54 +02:00
Johan LEROY 063092f2c7 fix(frontend): le rafraichissement de session au demarrage ne doit pas ecraser un lien de reset
Le refresh de session lance par provideAppInitializer echoue silencieusement
sans cookie valide, mais l'intercepteur forcait quand meme un
router.navigate(['/login']) sur le 401 resultant, ecrasant la navigation
vers /reset-password?token=... venue de l'email. L'intercepteur ne
redirige plus quand on est deja sur une route invitee (login,
forgot-password, reset-password).
2026-09-17 14:40:53 +02:00
ineszang44 1afaee069f fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:39:48 +02:00
ineszang44 7bc9a09489 fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:38:08 +02:00
Johan LEROY 921da48eb1 fix(auth): corrige 4 failles de la revue de securite sur la PR #90
Anti-enumeration cassee sur /auth/forgot-password : l'envoi SMTP etait
synchrone dans le chemin de reponse, donc un email existant prenait plus
de temps qu'un email inconnu (et pouvait renvoyer 500 si le relais SMTP
echouait, contre 202 sinon). L'envoi part desormais en BackgroundTasks,
apres que la reponse 202 a ete envoyee au client, avec un try/except qui
logue plutot que de laisser une exception SMTP remonter.

confirm_password_reset() ne revalidait pas is_active/kind du compte avant
de changer le mot de passe : un compte desactive dans les 15 minutes
suivant l'emission du lien pouvait quand meme voir son mot de passe
change et son must_change_password efface.

Les plages [A-ZA-Y]/[a-za-y] de la regle de complexite incluaient par
erreur x et / (U+00D7, U+00F7), donc un mot de passe sans aucune
majuscule ou minuscule pouvait passer la validation.

Le validateur frontend (JS, \w ASCII) et le validateur backend (Python,
\w Unicode) divergeaient sur les caracteres accentues : un mot de passe
comme "Securite1" passait cote front puis se faisait rejeter en 422 cote
back. Les deux cotes utilisent maintenant le meme jeu explicite de
caracteres speciaux (SPECIAL_CHARACTERS, partage aussi avec cli.py).
2026-09-17 14:35:14 +02:00
ineszang44 4ee2109628 fix(frontend): prise en compte du répertoire du reporter 2026-09-17 14:32:14 +02:00
Johan LEROYandGitHub ec1c05ad54 Merge pull request #102 from ineszang/feat/ml
feat(ml): initialise le pipeline d'entrainement LightGBM (ADR 0005)
2026-09-17 14:29:10 +02:00
ineszang44 334ca5982b fix(frontend): chemin vers lcov.info 2026-09-17 14:26:22 +02:00
ineszang44 2465021d61 fix(frontend): chemin vers lcov.info 2026-09-17 14:22:30 +02:00
ineszang44 278299c2b1 test: configuration sonarqube + config angular 2026-09-17 14:18:54 +02:00
Dorian 4f69199734 feat(ml): initialise le pipeline d'entrainement LightGBM (ADR 0005)
ML / Lint, typage et tests (push) Successful in 2m2s
2026-09-17 14:12:26 +02:00
Valentin 1cca4f130c chore(ci): ajoute la couverture de tests au job test du frontend
Frontend / build (push) Successful in 10m14s
Frontend / test (push) Failing after 5m1s
Frontend / SonarQube (push) Skipped
2026-09-17 13:55:17 +02:00
ValentinDeFariaandGitHub 016f226fdb Merge pull request #98 from ineszang/feat/scan-dépendances-dependabot
feat: ajout dependances dependabot
2026-09-17 13:43:25 +02:00
ValentinDeFariaandGitHub 88f4f9a601 chore(ci): ajoute la surveillance docker du frontend a dependabot 2026-09-17 13:42:02 +02:00
ineszang44 ed7311d4ef test: configuration sonarqube 2026-09-17 13:36:41 +02:00
ineszang44 ff68a51424 feat(frontend): fichier de config pour vitest 2026-09-17 13:36:21 +02:00
ineszang44 11f9b1bcd5 test(frontend): sonarqube 2026-09-17 13:04:36 +02:00
ineszang44 41c18a3bb1 test: sonarqube 2026-09-17 12:47:44 +02:00
ineszang44 00ef725249 test: sonarqube 2026-09-17 12:46:04 +02:00
ineszang44 34f35f3ca0 test: sonarqube 2026-09-17 12:26:04 +02:00
Valentin f5cac1c2a8 chore(ci): ajoute un audit de sécurité des dépendances (npm audit, pip-audit) 2026-09-17 12:25:15 +02:00
ineszang44 8e07168a5e test: ajout de propriétés dans le fichier de config pour sonarqube 2026-09-17 12:19:26 +02:00
Johan LEROY 916b5d246a Merge remote-tracking branch 'origin/dev' into feat/password-policy-forgot-password
# Conflicts:
#	apps/backend/app/api/deps.py
#	apps/backend/pyproject.toml
2026-09-17 12:18:48 +02:00
Johan LEROY d167b64188 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
2026-09-17 12:17:05 +02:00
Johan LEROYandGitHub 7913518c4b Merge pull request #94 from ineszang/feat/get-readings
feat(backend): expose GET /api/v1/readings avec fenetre bornee et pag…
2026-09-17 12:12:55 +02:00
Valentin 2ad7692f1c Ajoute la configuration Dependabot (npm, uv, github-actions, docker) 2026-09-17 12:12:48 +02:00
Dorian a158d6f84c Merge remote-tracking branch 'origin/dev' into feat/get-readings 2026-09-17 11:58:27 +02:00
ineszangandGitHub 7dfd7a7e74 Merge pull request #88 from ineszang/feat/data-import
Ajout du pipeline d'import des données historiques
2026-09-17 11:57:58 +02:00
Dorian 8d28113f03 feat(backend): expose GET /api/v1/readings avec fenetre bornee et pagination 2026-09-17 11:50:20 +02:00
Johan LEROY 62932e57c3 style(backend): formatage ruff de cli.py 2026-09-17 11:42:44 +02:00
Johan LEROY cd4fd962be fix(backend): regenere openapi.json avec le x-logo ajoute a main.py
Oublie apres l'ajout de l'extension x-logo dans create_app() : le contrat
versionne divergeait du schema genere, faisant echouer
test_the_committed_contract_matches_the_generated_one en CI.
2026-09-17 11:39:24 +02:00
Johan LEROY 517144f7e5 fix(frontend): lockup logo via texte reel plutot qu'un raster recadre
Recomposer icone+texte en une seule image bitmap (recadrage pixel de
l'asset source) donnait un rendu bruite et un espacement fige, impossible
a ajuster proprement (cause du "gros espace entre le texte et l'image"
remonte). Nouveau composant ev-brand : icone PNG nette + texte "EnerVision"
reel en police systeme, tailles liees en em pour que le lockup grossisse en
gardant le meme rapport, et un ecart controlable en CSS plutot que fige
dans un fichier image.
2026-09-17 11:36:29 +02:00
Johan LEROY cc7ca2d359 fix(frontend): logo plus grand et fond de page coherent sur toutes les pages
Logo des pages auth agrandi (64px -> 96px) : encore trop petit avec la
premiere passe. Fond de page (--color-bg) applique globalement sur body
plutot que par page, pour que le dashboard et les pages auth partagent le
meme socle visuel. Logo et respiration du dashboard ajustes en consequence.
2026-09-17 11:30:19 +02:00
Johan LEROY bbafe7d119 fix(frontend): logo/favicon corrects et pages d'authentification plus posees
Le src="logo.png" relatif resolvait mal sur les routes autres que "/" :
chemin absolu "/logo.png". Titre de page "Frontend" -> "EnerVision", favicon
regenere depuis l'icone reelle du logo. Pages login/change-password
retravaillees (fond degrade de marque, carte plus large, logo et titre plus
presents) via une classe .auth-page partagee plutot que dupliquee par page.
2026-09-17 11:29:07 +02:00
Johan LEROY 9c78c6dc38 feat(frontend): design système - tokens, composants ui et restylage des pages
Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page
(login, change-password, dashboard) en tokens CSS partagés, ajoute un petit
set de composants standalone réutilisables (ev-button, ev-card, ev-alert,
ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans
Swagger/ReDoc côté backend.

Refs #91
2026-09-17 11:14:02 +02:00
Johan LEROY 9161b74874 feat(auth): politique de complexite du mot de passe et flux de reinitialisation
Remplace la regle de longueur seule (12 caracteres) par une exigence de
composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere
special), non documentee dans les exigences officielles du projet, par une
regle explicite partagee entre le backend (validateur Pydantic) et le
frontend.

Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici :
jeton a usage unique hache en base (meme principe que les refresh tokens),
expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib,
Mailpit en dev), avec limitation de debit dediee et reponse generique pour
eviter l'enumeration des comptes.

Closes #87
2026-09-17 10:53:58 +02:00
Meryemel-gham 6798d35572 fix(data): corrige le typage de l'import historique
Backend / Lint, typage et tests (push) Successful in 1m16s
2026-09-17 10:41:18 +02:00
Meryemel-gham f03dce5fe3 style(data): applique le formatage Ruff 2026-09-17 10:06:54 +02:00
Meryemel-gham 74ac1b4577 docs(data): documente le pipeline d'import historique 2026-09-17 09:55:27 +02:00
Meryemel-gham ebb72fb399 test(data): couvre l'import historique 2026-09-17 09:34:30 +02:00
Meryemel-gham b2d52823ba fix(data): aligne l'import historique avec les contraintes BDD 2026-09-17 09:34:30 +02:00
Meryemel-gham fcbfcc8eb2 feat(data): ajoute l'import historique des donnees 2026-09-17 09:34:30 +02:00
Johan LEROYandGitHub 3692d486c6 Merge pull request #83 from ineszang/feat/endpoint-sensors-status
feat(backend): expose GET /api/v1/sensors/status
2026-09-17 09:16:24 +02:00
ValentinDeFariaandGitHub 24bf8bf4b9 Update apps/backend/tests/services/test_sensor.py
Backend / Lint, typage et tests (push) Successful in 1m11s
2026-09-17 09:10:04 +02:00
Johan LEROYandGitHub 9f465538bf Merge pull request #85 from ineszang/feat/auth-front
feat(frontend): authentification frontend
2026-09-17 09:09:59 +02:00
Johan LEROY 515a92b395 fix(frontend): isole les fichiers de tests vitest pour eviter la pollution de mocks
Le test site-load-chart.spec.ts echouait de facon intermittente en CI : sans
isolation, vitest partage le registre de modules entre fichiers de spec, donc
le mock chart.js d'un fichier pouvait ecraser celui d'un autre selon l'ordre
d'execution.
2026-09-17 09:02:53 +02:00
ValentinDeFariaandGitHub 0174272bdd Update dashboard.html 2026-09-16 17:00:52 +02:00
ineszangandGitHub c740b61b24 Merge pull request #80 from ineszang/feat/pipeline-ci
Feat/pipeline ci
2026-09-16 16:41:55 +02:00
Valentin 5669cd63ec feat(frontend): authentification frontend
ajout de la page login, changement de mot de passe forcé, rafraîchissement de session en mémoire, intercepteur, déconnexion, bouton logout sur le dashboard
2026-09-16 16:07:28 +02:00
Johan LEROY 2f97e4d434 fix(backend): corrige formatage ruff et typage mypy sur sites/current
CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non
annoté, assignation Literal non étroite). Corrige sans changer le
comportement.
2026-09-16 15:27:05 +02:00
Johan LEROY 07ea8d21dc feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29
Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant
la vérification d'existence déjà en place pour GET /sites/{site_id} :
SiteService gagne une dépendance ReadingRepository, sur le modèle de
composition déjà utilisé par StatsService/SensorService. Un site connu
sans lecture rend 200 avec les champs de mesure à null et
data_quality="critical" ; seul un site_id absent rend 404.
2026-09-16 15:25:14 +02:00
ineszang44 06cb60463c fix(frontend): droit d'accès au fichier de config de nginx, réduction de code smells 2026-09-16 15:12:04 +02:00
ineszang44 1c6b6105bd chore(frontend): ajout de la configuration nginx 2026-09-16 14:54:12 +02:00
Johan LEROY 77440281f8 feat(backend): expose GET /api/v1/sensors/status pour l'issue #32
Dérive l'état de santé de 5 capteurs par site et un statut overall depuis
la dernière lecture (data_quality, null_reasons, nullité des colonnes),
sur le gabarit d'agrégation de StatsService. Route réservée au rôle admin.
2026-09-16 14:53:54 +02:00
Johan LEROYandGitHub 63ee79cf32 Merge pull request #81 from ineszang/feat/endpoint-alertes-predictives
feat(backend): expose GET /api/v1/alerts
2026-09-16 14:44:27 +02:00
Johan LEROY 76fa90dfcb test(backend): exerce AlertSeverity comme enum plutot qu'une chaine dans les tests alerts
Backend / Lint, typage et tests (push) Successful in 1m8s
Le filtre severity passait par une chaine brute dans les tests, sans jamais
exercer le trajet reel AlertSeverity (enum) -> SQLAlchemy -> PostgreSQL.
2026-09-16 14:43:27 +02:00
ineszang44 6ecec1afef chore(frontend): ajout du dockerignore et du dockerfile 2026-09-16 14:40:55 +02:00
Johan LEROY e13096c62a Fusionne dev dans feat/endpoint-alertes-predictives
Resout les conflits additifs entre les routes alerts, recommendations
et stats mergees sur dev (PR #79, PR #82) pendant le developpement de
cette branche : deps.py, router.py, openapi.py, openapi.json,
test_openapi.py et 20-backend.md conservent desormais les trois routes.
2026-09-16 14:25:49 +02:00
ineszang44 970a4a50b8 fix(frontend): suppression de dépendance dans le service frontend 2026-09-16 14:22:50 +02:00
Johan LEROYandGitHub 3fb907d6f6 Merge pull request #82 from ineszang/feat/endpoint-recommandations
feat(backend): ajoute GET /recommendations et GET /recommendations/{r…
2026-09-16 14:21:08 +02:00
Dorian PESCE c7490d01b3 Merge remote-tracking branch 'origin/dev' into feat/endpoint-recommandations 2026-09-16 14:03:24 +02:00
Johan LEROYandGitHub 523b623dc1 Merge pull request #79 from ineszang/feat/stats-summary
feat(backend): expose GET /api/v1/stats/summary
2026-09-16 13:37:47 +02:00
Johan LEROY 61e031fc16 Fusionne dev dans feat/stats-summary
Resout les conflits de deps.py, router.py, repositories/site.py et
test_site.py entre l'ajout de stats et le merge de sites/openapi-contrat
sur dev. Generalise ROUTES_A_ROLE dans test_openapi.py et documente la
checklist d'ajout d'une route metier, absentes de dev au moment du fork.
2026-09-16 13:37:03 +02:00
Dorian PESCE 781644b28e feat(backend): ajoute GET /recommendations et GET /recommendations/{recommendation_id} 2026-09-16 13:30:51 +02:00
Johan LEROY 1654e4dd81 docs(backend): documente la checklist d'ajout d'une route metier
La generalisation de ROUTES_A_ROLE (commit precedent) avait deja ete
approuvee sur feat/openapi-contrat mais poussee apres la fermeture de
la PR #76 : elle n'a donc jamais atteint dev, et sa documentation non
plus. Complete ce qui manquait pour que le passage a l'echelle du
contrat OpenAPI soit reellement utilisable par la prochaine route.
2026-09-16 13:27:41 +02:00
Johan LEROY e50921c907 feat(backend): expose GET /api/v1/alerts
Consultation des alertes de consommation, filtrable par site_id et
severity a l'identique du contrat GET /alerts de l'API Mock. Reprend
le gabarit endpoints -> services -> repositories -> models pose par
sites, sur la table alert deja creee par la revision Alembic
e6d2026091501.

Generalise aussi le garde-fou OpenAPI du 403 (ROUTES_A_ROLE) au-dela
du seul tag users, pour que l'ajout d'alerts a la liste des routes
protegees par role soit reellement verifie.

Closes #59
2026-09-16 13:06:37 +02:00
PhyriosandGitHub 56f7211f0b Merge pull request #77 from ineszang/feat/frontend-make-dev
feat(build): branche le frontend sur make dev
2026-09-16 12:34:41 +02:00
ineszang44 730adb69b1 chore(frontend): faux positifs cwe 2026-09-16 12:32:28 +02:00
ineszang44 f43c9f76a0 test(frontend): workflow 2026-09-16 12:29:54 +02:00
Johan LEROY c83fd889b8 Fusionne dev dans feat/frontend-make-dev
Combine les cibles install-backend/install-frontend/dev-backend/dev-frontend
introduites ici avec la cible openapi ajoutee par PR #76 (merge de dev).
2026-09-16 12:21:20 +02:00
ineszang44 04e4913952 fix(frontend): code smells 2026-09-16 12:20:15 +02:00
Johan LEROYandGitHub cf22b2ae55 Merge pull request #76 from ineszang/feat/openapi-contrat
feat(backend): documente et verse le contrat OpenAPI
2026-09-16 12:05:27 +02:00
Johan LEROY 12c5cf87ad 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é.
2026-09-16 12:04:32 +02:00
Johan LEROYandGitHub 7b076171d2 Merge pull request #78 from ineszang/feat/endpoints-get-sites-et-get-site
feat/endpoints get sites et get site
2026-09-16 11:55:56 +02:00
Johan LEROY ad149db0cb fix(backend): corrige une assertion tautologique dans test_list_all_returns_the_sites_sorted_by_identifier
Backend / Lint, typage et tests (push) Successful in 1m7s
L'assertion comparait le résultat à lui-même trié, donc vraie quel que
soit l'ordre réellement renvoyé par SiteRepository.list_all(). Compare
désormais à des identifiants connus à l'avance.
2026-09-16 11:55:03 +02:00
Johan LEROY 50dcb4de32 feat(backend): expose GET /api/v1/stats/summary
Ajoute le resume instantane de consommation du parc attendu par le
frontend (deja developpe contre ce contrat en mode mock). Nouveaux
SiteRepository et ReadingRepository (derniere lecture par site via
DISTINCT ON), StatsService pour l'agregation et les cas de repli
(capacite nulle, absence de lecture, data_quality inconnue), et le
endpoint lecteur-seul correspondant. Documentation des routes et du
schema des couches mises a jour.
2026-09-16 11:45:58 +02:00
Dorian PESCE d25e544db6 fix(backend): contourne un bug de ruff format sur le except à deux types de health.py 2026-09-16 11:41:53 +02:00
ineszang44 d1e4d8cfa0 test(frontend): lancement automatique du workflow après un push ou avec une pull request 2026-09-16 11:31:10 +02:00
Dorian PESCE 22ff1d93f4 fix(backend): type le retour de SiteRepository.get_by_id pour mypy strict 2026-09-16 11:29:18 +02:00
ineszang44 1f0eb410eb test(frontend): suppression des conditions if 2026-09-16 11:28:49 +02:00
ineszang44 078983a41d test(frontend): suppression de la propriété 'pull-request' 2026-09-16 11:25:55 +02:00
Johan LEROYandGitHub d632af57b8 Merge pull request #74 from ineszang/docs/coherence-40-data
docs(architecture): remet 40-data.md en accord avec le schema livre
2026-09-16 11:25:43 +02:00
ineszang44 596cf43eda test(frontend): lancement manuel du workflow 2026-09-16 11:22:58 +02:00
Johan LEROY fabd073aaf fix(backend): documente le 403 CSRF de require_trusted_origin
Le contrat OpenAPI et 31-contrat-authentification.md passaient sous
silence le 403 leve par require_trusted_origin sur refresh, logout,
logout-all et password. Ajoute REPONSE_ORIGINE_REFUSEE, regenere
openapi.json et etend test_openapi.py pour verifier que ces quatre
routes le declarent.
2026-09-16 11:22:40 +02:00
Dorian PESCE 31a9cb109f fix(backend): corrige la syntaxe except invalide de la sonde /health/ready 2026-09-16 11:04:27 +02:00
Dorian PESCE 50dddf952b feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} 2026-09-16 11:03:06 +02:00
Dorian PESCE fc6600aeaf Revert "feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id}"
This reverts commit 1325a75e9a.
2026-09-16 11:02:00 +02:00
Dorian PESCE 1325a75e9a feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} 2026-09-16 11:01:13 +02:00
Johan LEROY 16a0cc4d3b fix(build): stabilise make dev pour le frontend
Desactive le prompt d'analytics Angular CLI (bloquait ng serve en
sous-processus non interactif) et affiche les URLs backend/frontend au
demarrage de make dev.
2026-09-16 10:36:31 +02:00
ineszang44 11baea7117 changement d'ordre des jobs + ajout des dépendances entre les jobs 2026-09-16 10:29:47 +02:00
Johan LEROY 5e7cb005ac feat(build): branche le frontend sur make dev
Ajoute install-frontend/dev-frontend au Makefile, dev/install deviennent
composites (backend + frontend lances ensemble), et met a jour README et
docs/architecture en consequence.

Closes #75
2026-09-16 10:25:22 +02:00
Johan LEROY 3347fa5bdb docs(architecture): acte le contrat OpenAPI dans la vue backend
20-backend.md gagne une section qui dit où vit le schéma, comment on le
régénère, pourquoi il est versionné en plus d'être servi, et pourquoi servers,
license_info et contact restent absents. La table des routes gagne la colonne
des codes d'erreur déclarés.

31-contrat-authentification.md renvoyait le frontend vers /docs, donc vers une
API qui tourne. Il renvoie maintenant vers le fichier, lisible sans rien
lancer.
2026-09-16 10:14:19 +02:00
Johan LEROY da481d7485 feat(backend): verse le contrat OpenAPI au dépôt et le garde honnête
`make openapi` écrit apps/backend/openapi.json, et un test compare le fichier
versionné au schéma généré. Une route qui change son contrat public le montre
donc dans la diff d'une pull request, et une PR qui oublie de régénérer échoue
en CI : le fichier vit sous apps/backend, que le filtre de chemins de
backend.yml couvre.

Le schéma exporté ne lit ni le .env du poste ni les variables APP_ : tout ce
qui l'atteint est posé par settings_du_contrat(), sans quoi le fichier
changerait de machine en machine.

main() réclamait un mot de passe avant de lire la commande. Le branchement
passe devant, sinon l'export serait resté bloqué sur getpass.
2026-09-16 10:12:50 +02:00
Johan LEROY 344f82fcdd feat(backend): documente le contrat d'erreur dans l'OpenAPI
Le schéma ne déclarait aucun code d'erreur : ni 401, ni 403, ni 404, ni 409,
ni 429. Swagger affirmait que /auth/login ne pouvait répondre que 200 ou 422,
alors que 31-contrat-authentification.md décrit ces codes comme le contrat que
le frontend doit traiter.

Le 422 publié était pire qu'absent : le schéma exposait HTTPValidationError,
le modèle par défaut de FastAPI avec sa clé `loc`, quand
validation_error_handler renvoie {"detail": [{"champ", "type"}]}. Un client
codé sur la documentation lisait une clé qui n'arrive jamais.

Les métadonnées arrivent avec : description, résumé et une description par
tag. `servers`, `license_info` et `contact` restent absents, ils poseraient
des décisions qui ne sont pas prises.

Le cookie de rafraîchissement devient visible par un APIKeyCookie en
auto_error=False, purement documentaire : lit_le_cookie() reste seul maître du
401 de /auth/refresh.

Au passage, health.py posait son tag deux fois, une fois sur son APIRouter et
une fois à l'include_router.
2026-09-16 10:12:41 +02:00
ineszang44 61b3494d12 correction nom du workflow pour le frontend 2026-09-16 10:03:59 +02:00
Johan LEROYandGitHub 44468e85d7 Merge pull request #73 from ineszang/feat/dashboard-graph
Feat/dashboard graph
2026-09-16 09:42:55 +02:00
Johan LEROY 580da72eff docs(architecture): acte le tableau de bord dans les vues frontend
30-frontend.md decrivait encore un ng new intact : routes vides,
provideHttpClient absent, app.html par defaut, aucune bibliotheque de
graphiques. Les sections Arborescence et Flux HTTP passent de Cible a
realisees, et le diagramme de sequence montre ou l'intercepteur se place.

La section Securite affirmait que l'authentification n'existe pas cote API :
elle existe depuis la PR #70, c'est cote interface qu'il n'y a rien.

Ajout verifie sur le poste : l'Angular CLI refuse de demarrer en dessous de
Node 22.22.3, 24.15.0 ou 26.0.0.
2026-09-16 09:23:15 +02:00
Johan LEROY e85c83972a chore(frontend): sort le rapport JUnit du suivi git
La ligne /test-results ajoutee au .gitignore n'avait aucun effet : le fichier
etait deja suivi, et un .gitignore ne s'applique pas a un fichier indexe. Il
reapparaissait donc modifie dans le diff de chacun a chaque execution de ng
test, qui le regenere a l'emplacement fixe par angular.json.
2026-09-16 09:20:48 +02:00
Johan LEROY da97e6aa8b style(frontend): applique prettier aux fichiers du tableau de bord
Les onze fichiers non conformes au .prettierrc du projet etaient exactement
ceux introduits ou modifies par cette branche ; les vingt-deux autres du
frontend etaient deja propres.

Aucune modification de comportement : indentation, virgules finales et
longueur de ligne a 100 caracteres.
2026-09-16 09:20:40 +02:00
Johan LEROY 0259f66b62 fix(frontend): detruit les graphiques avec leur composant
Chart.js conserve chaque instance dans un registre lie au canvas et lui
attache un observateur de redimensionnement. Sans destroy, tout survit a la
destruction du composant, et une re-creation sur le meme canvas echoue avec
"Canvas is already in use".

Les doubles de test gagnent destroy : TestBed detruit les fixtures apres
chaque test, un mock sans cette methode fait tomber les specs existantes.
2026-09-16 09:20:17 +02:00
Johan LEROY 7b9406965e fix(frontend): maintient le rafraichissement du tableau de bord en cas d'erreur
Sans catchError, la premiere reponse en erreur terminait le flux du timer :
le rafraichissement ne repartait jamais et l'ecran restait fige sur des
chiffres perimes, sans rien signaler.

Le catchError porte sur l'observable interne du switchMap. Place sur le flux
externe il terminerait le timer tout autant. Un signal error alimente un
bandeau, efface des qu'une reponse valide revient.
2026-09-16 09:20:17 +02:00
Johan LEROY 881f503f1a Merge remote-tracking branch 'origin/dev' into feat/dashboard-graph 2026-09-16 09:15:26 +02:00
Johan LEROYandGitHub 918bd971da Merge pull request #71 from ineszang/feat/data-schema
feat(apps): ajoute le schéma de données EnerVision
2026-09-16 08:52:04 +02:00
Johan LEROY 6c1f86b4ce docs(architecture): remet 40-data.md en accord avec le schema livre
L'avertissement affirmait qu'aucune table applicative n'existait, vingt lignes
avant la liste des tables d'authentification. La section « Modèle métier »
décrivait un modèle candidat que la « Modélisation détaillée » contredit
depuis la livraison du schéma : elle disparaît, et le gabarit d'hypertable
s'appuie désormais sur la révision réelle.

Les conventions annonçaient une colonne de partitionnement nommée horodatage,
alors qu'elle s'appelle timestamp. Les six tables data prennent leur nom au
singulier, et les questions tranchées par le schéma sortent des questions
ouvertes.
2026-09-16 08:47:10 +02:00
Johan LEROY 3eb5a0e8dc style(backend): applique ruff format au modele data
Backend / Lint, typage et tests (push) Successful in 1m7s
La cible make check ne lance que ruff check ; la CI lance en plus
ruff format --check, qui refusait la contrainte unique repliee.
2026-09-16 08:43:12 +02:00
Johan LEROY c733ccfc62 refactor(backend): passe les tables data au singulier et clarifie alert_id
La convention de docs/architecture/40-data.md impose des noms de tables au
singulier, que les quatre tables d'authentification respectent déjà. Les six
tables data passent donc au singulier, avec leurs contraintes et leurs index.

La révision n'étant appliquée que sur des bases locales, elle est modifiée sur
place plutôt que doublée d'une migration de renommage.

alert_id désignait deux colonnes différentes : la clé métier text de l'API Mock
et la clé étrangère bigint de recommendation. La première devient
source_alert_id, la seconde pointe désormais vers alert.alert_id.
2026-09-16 08:41:48 +02:00
ineszang b5cffbf56f fix(frontend): changement de version des actions pour raisons de compatibilité 2026-09-15 16:59:34 +02:00
valentin cdef30736a Creation dashboard (graph chart.js) + tests 2026-09-15 16:48:55 +02:00
Johan LEROY e3e0e843d0 fix(backend): rebranche la revision data sur la tete d'authentification
Le merge de dev apporte trois revisions d'authentification qui partent de la
meme racine 5353c0e4f094 que la revision data. Git ne signale rien, mais
alembic upgrade head refuse de choisir entre deux tetes.

La revision data se greffe desormais sur 821f71be74c0, ce qui rend la chaine
lineaire.
2026-09-15 16:46:37 +02:00
ineszang 3ef7de5baa feat(frontend): ajout chemins dans le pipeline CI 2026-09-15 16:46:16 +02:00
Johan LEROY c3b7c818aa Merge remote-tracking branch 'origin/dev' into feat/data-schema
# Conflicts:
#	apps/backend/app/models/__init__.py
2026-09-15 16:46:16 +02:00
ineszang ff6e3c288c feat(frontend): ajout du job de build 2026-09-15 16:35:50 +02:00
PhyriosandGitHub 935782bbca Merge pull request #70 from ineszang/feat/auth-backend
feat(backend): authentification et autorisation de bout en bout
2026-09-15 16:34:32 +02:00
Meryemel-gham c04ce9a9ae test(apps): verifie les contraintes du schema data 2026-09-15 16:16:40 +02:00
Meryemel-gham 128133761f feat(apps): cree les six tables data et l'hypertable readings 2026-09-15 16:16:18 +02:00
Meryemel-gham b032f084fc fix(apps): gere les caracteres encodes dans l'URL Alembic 2026-09-15 16:15:21 +02:00
ineszang 2390e58f78 chore: sonarqube 2026-09-15 16:10:39 +02:00
ineszang b300be5186 chore: init de la config du frontend sur docker compose 2026-09-15 16:10:18 +02:00
ineszang b8f806518f feat(frontend): ajout sonarqube dans le pipeline 2026-09-15 16:08:31 +02:00
Johan LEROYandGitHub 6c4684a4f6 Merge pull request #69 from ineszang/feat/data-ia-setup
docs: ajout du schéma de données et de sa description
2026-09-15 15:13:23 +02:00
Johan LEROY bfbd9ee2cc test(backend): couvre le changement de mot de passe
Backend / Lint, typage et tests (push) Successful in 1m7s
`AuthService.change_password` n'avait aucun test unitaire, alors qu'il
porte la promesse que l'appareil courant reste connecté pendant que tous
les autres tombent. Deux cas : le nominal, où une seule session est
rouverte après la révocation, et le refus quand le mot de passe actuel
est faux, qui ne doit rien révoquer.
2026-09-15 15:06:20 +02:00
Johan LEROY 3b7383697e docs: acte les décisions d'authentification et met à jour les vues
Trois ADR : le jeton d'accès et le rafraîchissement opaque, le RBAC avec
relecture du compte à chaque requête, et le journal d'audit en ajout
seul. Chacun porte ses alternatives écartées et son critère de bascule,
notamment celui vers OIDC.

`31-contrat-authentification.md` est destiné au frontend : endpoints,
codes d'erreur à traiter, et les quatre règles qui comptent. La
troisième, un seul rafraîchissement en vol, est une exigence et non une
optimisation : cinq rotations concurrentes seraient lues comme un rejeu
et révoqueraient la session à chaque chargement de page.

`owasp-traceabilite.md` remplace la revendication « couverture OWASP Top
10 et API Top 10 » de la NFR4, qui n'a pas de réponse honnête sur vingt
items en deux semaines. Un contrôle par ligne, l'item adressé, et une
section qui dit ce qui reste ouvert : portée par site, bornage des
lectures de séries, transport, et la consommation de l'API Mock.

Les vues 00, 20 et 40 suivent, comme l'impose leur propre règle de
maintenance. La question ouverte « quel mécanisme d'authentification »
est fermée ; trois autres la remplacent, dont la portée par site.
2026-09-15 15:05:28 +02:00
Johan LEROY c60081a5ac test(backend): isole les tests d'audit par cible unique
Ils interrogeaient `audit_log` sans filtre, ce qui supposait une table
vide. Le parcours d'authentification y écrit désormais de vraies lignes,
et comme la table est en ajout seul, elles ne s'effacent pas entre deux
exécutions. Chaque test filtre maintenant sur son propre `target_id`.
2026-09-15 14:58:15 +02:00
Johan LEROY cb2ac8c2c2 test(backend): éprouve le parcours d'authentification contre la vraie base
Six scénarios bout en bout, sans serveur ni port ouvert : connexion,
rotation, déconnexion, rejeu d'un cookie déjà tourné, révocation
immédiate et enregistrement d'une tentative sur adresse inconnue.

Le scénario du rejeu vérifie aussi que la session encore vivante tombe
avec sa famille : c'est la propriété qui distingue la détection de la
simple rotation, et elle ne se démontre pas sur un double.

Corrige un défaut que ce parcours a révélé : `iat` est une date JWT, donc
en secondes entières, et `datetime.fromtimestamp` tronque. Tout jeton
émis dans la même seconde que `credentials_changed_at` était rejeté, ce
qui aurait déconnecté l'appareil courant à chaque changement de mot de
passe, exactement l'inverse de ce que `/auth/password` promet.
2026-09-15 14:57:42 +02:00
ineszangandGitHub 83392c7ff4 chore: init pipeline frontend 2026-09-15 14:56:33 +02:00
Johan LEROY e8f22bf427 feat(backend): durcit la surface exposée
En-têtes de sécurité, CORS resserré, caviardage des journaux, `/metrics`
derrière un jeton facultatif, documentation fermée en préproduction, et
la sonde de disponibilité cesse de publier la version de TimescaleDB.

HSTS et CSP sont volontairement absents : l'application ignore si TLS
termine devant elle, et une CSP sur une API JSON ne protège presque rien.
Les deux appartiennent au terminateur TLS, celle qui compte protège la
page Angular.

`/metrics` est gardé par un jeton statique et non par un rôle : coupler
la supervision au modèle d'utilisateurs casserait la collecte à chaque
panne d'authentification, c'est-à-dire quand on en a le plus besoin. Le
contrôle principal reste le réseau.

Le caviardage est la troisième ligne de défense, pas la première. On ne
passe aucun secret au logger et aucun jeton dans une URL ; le filtre
rattrape ce que personne n'a relu, à commencer par l'écho SQL qui
publiait les empreintes Argon2 quand `debug` est actif.

Corrige un défaut que le test a révélé : `create_app(settings)` ne
pilotait que la construction, les dépendances continuaient de lire
`get_settings()` depuis l'environnement. Un test « en production » ne
testait donc pas la production, et `TESTING.md` promet le contraire.
2026-09-15 14:56:17 +02:00
Johan LEROY 7fdd6513ca feat(backend): ouvre l'administration des comptes et le changement de mot de passe
Liste, création, changement de rôle, activation, réinitialisation, plus
`/auth/password` pour son propre mot de passe.

Les schémas de lecture et d'écriture sont séparés : un modèle unique
laisserait passer `role` ou `is_active` depuis un corps de requête et
renverrait `password_hash` en réponse, soit l'attribution de masse, API3
du top 10 API. Un test envoie ces deux champs et vérifie qu'ils sont
ignorés.

Le service refuse de rétrograder ou de désactiver le dernier
administrateur actif. Sans cette garde, un administrateur peut se
verrouiller lui-même dehors et il ne reste que `psql` pour rentrer.

Tout changement de rôle ou désactivation révoque les sessions de la
cible, et `credentials_changed_at` rend le jeton d'accès encore valide
inutilisable dès la requête suivante. La promesse de révocation
immédiate ne tient que si les deux sont faits.

Le changement de son propre mot de passe révoque toutes les familles puis
en rouvre une : l'appareil courant reste connecté, tous les autres sont
déconnectés. Il faut le coder explicitement pour l'obtenir.

Les mots de passe provisoires sont tirés au sort et affichés une seule
fois, sous `Cache-Control: no-store`.
2026-09-15 14:52:29 +02:00
Meryemel-gham cf9c707592 docs(docs): ajoute schema de donnees et sa description 2026-09-15 14:52:14 +02:00
Johan LEROY 1f6210698d feat(backend): fait tourner les jetons de rafraîchissement et détecte leur réutilisation
Le jeton de rafraîchissement est une chaîne opaque de 256 bits, jamais un
JWT. Il doit être révocable, donc sa ligne en base existe de toute façon,
et le JWT n'ajouterait qu'un second chemin de signature. Surtout, la
séparation d'avec le jeton d'accès devient structurelle : un JWT ne
figure dans aucune ligne, une chaîne opaque échoue au décodage. La
confusion refresh-vers-accès, qui transforme une fenêtre de 15 minutes en
fenêtre de 7 jours, est impossible même si quelqu'un oublie le test.

Seule l'empreinte SHA-256 est stockée. Pas d'Argon2 : l'entrée fait
256 bits de CSPRNG, aucun dictionnaire ne l'atteint, et une KDF coûterait
17 ms à chaque rafraîchissement.

La rotation ne protège de rien par elle-même : elle rend la réutilisation
détectable, et c'est la détection qui termine le vol. Un jeton déjà
tourné révoque donc toute sa famille et laisse une trace dans
`audit_log` ; un jeton expiré, lui, ne révoque rien, ce n'est pas une
preuve de compromission. Les deux cas ont leur test.

La revendication est une seule instruction SQL avec RETURNING. Un SELECT
puis un UPDATE laisseraient une fenêtre où deux onglets réussissent la
même rotation ; le test d'intégration le prouve, ce qui est
indémontrable sur un double.

`expires_at` est absolu et hérité du prédécesseur : s'il glissait, la
promesse de sept jours serait fictive.

Corrige au passage un défaut trouvé par un test : une `HTTPException`
construit sa propre réponse, donc l'effacement du cookie posé sur la
`Response` injectée était perdu. Un navigateur gardait un cookie mort
après une détection de réutilisation.
2026-09-15 14:49:30 +02:00
Johan LEROY ef933bea1a feat(backend): authentifie par mot de passe et refuse les routes par défaut
Connexion, lecture du compte connecté, RBAC à trois rôles ordonnés et
limitation de débit à fenêtre glissante. Ajoute `login_attempt`, le
compteur de la limitation, et `audit_log`, en ajout seul.

Trois ordres d'exécution portent la sécurité de ce commit, et chacun a
son test :

- les compteurs sont lus AVANT le hachage Argon2, sinon chaque requête
  rejetée coûterait quand même 17 ms et 19 Mio, et la protection serait
  l'amplificateur de déni de service qu'elle doit empêcher ;
- un haché leurre est vérifié quand l'adresse est inconnue, sinon l'écart
  entre 2 ms et 17 ms est un oracle d'existence de compte ;
- la tentative échouée est validée en base avant que l'erreur ne soit
  levée, `get_session()` ne validant pas de lui-même.

Pas de verrouillage de compte : il suffirait de cinq requêtes pour mettre
un administrateur dehors, et il ne fait rien contre le bourrage
d'identifiants horizontal. Trois seuils le remplacent, dont un par couple
(identifiant, IP) qui garantit qu'un attaquant ne peut pas empêcher la
victime de se connecter depuis sa propre adresse.

`audit_log` est en ajout seul au niveau de PostgreSQL, par deux
déclencheurs. Le second n'est pas redondant : TRUNCATE ne passe pas par
les déclencheurs de ligne.

`test_route_protection.py` interroge réellement chaque route sans jeton.
Rendre une route publique impose donc de modifier une liste dans un
fichier de test, ce qui se voit en revue.

Le gestionnaire de 422 arrive ici et non plus tard : la réponse par
défaut de FastAPI contient la valeur rejetée, donc le mot de passe. Le
test qui le prouve serait rouge sans lui.
2026-09-15 14:41:25 +02:00
Johan LEROY a8f59e6e76 feat(backend): ajoute les comptes applicatifs et l'amorçage du premier admin
Table `app_user`, son dépôt, et la commande `create-admin`. Le nom évite
`user`, mot réservé de PostgreSQL, et rappelle qu'il s'agit d'un compte
applicatif, par opposition au rôle PostgreSQL qui portera le
cantonnement des accès ETL et ML.

`credentials_changed_at` couvre à elle seule le changement de mot de
passe, le changement de rôle et la désactivation : tout jeton émis avant
cet instant sera refusé, sans attendre son expiration.

La configuration refuse désormais de démarrer sur cinq erreurs
silencieuses : secret trop court ou laissé à sa valeur d'exemple, `debug`
en production, joker CORS, origines vides hors local, et cookie
`SameSite=None` sans `Secure`. Les fixtures de test et les deux
`.env.example` suivent, sans quoi rien ne démarrerait.

Le mot de passe de l'admin ne transite jamais par `argv`, visible de tout
`ps` : il est saisi par `getpass` ou tiré au sort. Une révision Alembic
qui insérerait ce compte graverait son empreinte dans Git pour toujours.
2026-09-15 14:30:05 +02:00
Johan LEROY 53af7a76d8 feat(backend): pose les primitives de sécurité
Couche pure, sans FastAPI ni session : rôles ordonnés, `Principal`,
encodage et décodage des jetons d'accès, empreinte des jetons de
rafraîchissement, et hachage Argon2id poussé dans un fil borné.

Aucun de ces modules ne lit `get_settings()`, mis en cache par
`lru_cache` et donc contaminé entre tests : les paramètres arrivent par
`TokenPolicy` et par `build_hasher()`.

Argon2id est calibré à m=19456 KiB, t=2, p=1, soit 17 ms mesurés sur un
poste de développement.
2026-09-15 14:25:41 +02:00
Johan LEROY 008cf581a7 ci(backend): vérifie format, lint, typage et tests à chaque poussée
`.github/workflows/` ne contenait qu'un `.gitkeep` alors que l'EC03
évalue la CI en continu. Périmètre volontairement minimal, aligné sur
`make check` : le scan de sécurité et la construction d'image relèvent
du chantier CI/CD et viendront l'étendre.
2026-09-15 14:22:09 +02:00
Johan LEROY e4d1b43a44 style(backend): rétablit les accents dans les messages et commentaires
Le français du dépôt s'écrit accentué. Harmonise les commentaires
d'en-tête, les docstrings, le message de démarrage et les deux détails
d'erreur de la sonde de disponibilité, avec leurs assertions.
2026-09-15 14:21:46 +02:00
Johan LEROYandGitHub dcdee8fc6e Merge pull request #68 from ineszang/docs/architecture
docs: fonder la documentation d'architecture du monorepo
2026-09-15 13:01:33 +02:00
PhyriosandGitHub e47235bd7f Mise à jour des jalons 2026-09-15 12:33:15 +02:00
Johan LEROY 4c72fbbb69 docs: fonde les vues d'architecture du monorepo
Cinq vues Mermaid dans docs/architecture (vue d'ensemble, infra, backend,
frontend, donnees), plus leur index, les conventions de statut et la regle
de maintenance en PR.

Reprend les jalons J1-J4, disparus de dev lors de la reecriture du README
(2670483) et restes seulement sur main : plus rien sur la branche de travail
ne disait ce que le projet doit prouver.

Fige les decisions du module Terraform k3s, qui ne vivaient jusqu'ici que
dans des commentaires de code et des description de variables : version
epinglee obligatoire, Traefik desactive, kubeconfig en 600/root, state local.

Corrige trois affirmations devenues fausses : le frontend classe
"a initialiser" alors que le squelette existe depuis 49f4697, le port 4200
dit attendu par docker-compose.yml qui n'a aucun service frontend, et
l'arborescence core/ prescrite par TESTING.md sans exister.
2026-09-15 11:56:18 +02:00
PhyriosandGitHub c3fd9327ea Definition des jalons 2026-09-14 11:30:01 +02:00
340 changed files with 39801 additions and 494 deletions
+27
View File
@@ -0,0 +1,27 @@
# Dépendances (réinstallées dans l'image)
node_modules/
vendor/
__pycache__/
*.pyc
# Git et IDE
.git/
.gitignore
.vscode/
.idea/
*.swp
# Fichiers de build locaux
dist/
build/
*.log
# Secrets et config locale (CRITIQUE : risque d'exfiltration)
.env
.env.local
*.pem
*.key
secrets/
.npmrc
.pypirc
kubeconfig
+43 -5
View File
@@ -1,17 +1,55 @@
# Variables lues par docker-compose.yml a la racine.
# Le backend lance hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier.
# Variables lues par docker-compose.yml à la racine.
# Le backend lancé hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier.
POSTGRES_USER=enervision
POSTGRES_PASSWORD=change_me
POSTGRES_DB=enervision
# 5432 est souvent deja pris par une autre base du poste.
# 5432 est souvent déjà pris par une autre base du poste.
POSTGRES_PORT=5433
# `basic` renvoie des statistiques d'usage a Timescale.
# `basic` renvoie des statistiques d'usage à Timescale.
TIMESCALEDB_TELEMETRY=off
APP_ENV=local
APP_DEBUG=true
APP_DEBUG=false
APP_LOG_LEVEL=INFO
# L'API refuse de démarrer tant que cette valeur reste un exemple ou fait moins de
# 32 caractères. Générer la vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200
BACKEND_PORT=8000
FRONTEND_PORT=3000
# Mailpit capture les courriels du backend, rien ne sort vers l'extérieur.
MAILPIT_SMTP_PORT=1025
MAILPIT_UI_PORT=8025
# API Mock EnerVision
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10
# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
AIRFLOW_PORT=8080
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
AIRFLOW_FERNET_KEY=change_me
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_WEBSERVER_SECRET_KEY=change_me
AIRFLOW_ADMIN_USERNAME=admin
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
# comptes `app_user` d'EnerVision.
AIRFLOW_ADMIN_PASSWORD=change_me
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
# `APP_SECRET_KEY` du backend, que le DAG `alertes` lance en sous-processus. Distincte de
# celle de l'API : la détection ne signe aucun jeton, et Airflow exécute du code depuis son
# interface (cf. ADR 0008). Générer la vôtre :
# python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_APP_SECRET_KEY=change_me
# Stack complète derrière le reverse proxy (docker-compose.prod.yml).
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
PUBLIC_HOST=enervision.local
ACME_EMAIL=
+46
View File
@@ -0,0 +1,46 @@
version: 2
updates:
# Frontend — npm
- package-ecosystem: "npm"
directory: "/apps/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
frontend-dependencies:
patterns:
- "*"
# Backend — uv (lit pyproject.toml / uv.lock)
- package-ecosystem: "uv"
directory: "/apps/backend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
backend-dependencies:
patterns:
- "*"
# Les workflows GitHub Actions eux-mêmes ont aussi des dépendances à jour
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Si un Dockerfile existe pour le backend
- package-ecosystem: "docker"
directory: "/apps/backend"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/apps/frontend"
schedule:
interval: "weekly"
# Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose
- package-ecosystem: "docker-compose"
directory: "/"
schedule:
interval: "weekly"
+101
View File
@@ -0,0 +1,101 @@
name: Airflow
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
#
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API.
on:
push:
paths:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
pull_request:
paths:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
permissions:
contents: read
concurrency:
group: airflow-${{ github.ref }}
cancel-in-progress: true
jobs:
verification:
name: Lint et intégrité des DAGs
runs-on: ubuntu-latest
defaults:
run:
working-directory: etl/airflow
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
- name: Vérifie le formatage
run: uv run ruff format --check .
- name: Analyse statique
run: uv run ruff check --output-format=github .
# Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et
# vérifie import, planification, plafonds d'exécution et commande de chaque tâche.
- name: Tests d'intégrité des DAGs
run: uv run pytest
image:
name: Construction de l'image
runs-on: ubuntu-latest
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
# Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent
# (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé.
- name: Vérifie que le pipeline ML s'importe sans réseau
run: >
docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
# l'import du module prouve que l'environnement /opt/backend est complet. Les deux
# commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui.
- name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau
run: >
docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/backend
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help"
+168
View File
@@ -0,0 +1,168 @@
name: Backend
# Piège : la version de Python vient de apps/backend/.python-version, et elle doit rester
# en 3.14. Le code utilise le PEP 758, qu'un interpréteur 3.13 refuse de compiler.
on:
push:
paths:
- "apps/backend/**"
- ".github/workflows/backend.yml"
pull_request:
paths:
- "apps/backend/**"
- ".github/workflows/backend.yml"
permissions:
contents: read
concurrency:
group: backend-${{ github.ref }}
cancel-in-progress: true
jobs:
verification:
name: Lint, typage et tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
- name: Vérifie le formatage
run: uv run ruff format --check .
- name: Analyse statique
run: uv run ruff check --output-format=github .
- name: Typage
run: uv run mypy app
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
- name: Tests et couverture
run: uv run pytest --cov-fail-under=85
# Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première
# migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un
# écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre.
integration:
name: Tests exigeant une base
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
services:
db:
image: timescale/timescaledb-ha:pg17
env:
POSTGRES_USER: enervision
POSTGRES_PASSWORD: change_me
POSTGRES_DB: enervision_test
ports:
- "5433:5432"
options: >-
--health-cmd "pg_isready -U enervision -d enervision_test"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 40s
env:
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test
APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
# Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est
# pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1.
- name: Active TimescaleDB sur la base de test
run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
- name: Applique les migrations
run: uv run alembic upgrade head
# `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job
# ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %.
- name: Tests d'intégration
run: uv run pytest -m integration --no-cov
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
# L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait
# aussi les paquets que son propre `--with` injecte, hors dépendances du projet.
- name: Audite les dépendances livrées
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
shell: bash
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
sast:
name: Analyse statique de sécurité
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@v7
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
- name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium
# Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace.
- name: Rapport complet, tous niveaux
continue-on-error: true
run: uvx bandit==1.9.4 --recursive app
+64
View File
@@ -0,0 +1,64 @@
name: Frontend
on:
push:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
pull_request:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v7
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
+82
View File
@@ -0,0 +1,82 @@
name: ML
# Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf.
# .github/workflows/backend.yml, même contrainte).
on:
push:
paths:
- "ml/**"
- ".github/workflows/ml.yml"
pull_request:
paths:
- "ml/**"
- ".github/workflows/ml.yml"
permissions:
contents: read
concurrency:
group: ml-${{ github.ref }}
cancel-in-progress: true
jobs:
verification:
name: Lint, typage et tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: ml
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
- name: Vérifie le formatage
run: uv run ruff format --check .
- name: Analyse statique
run: uv run ruff check --output-format=github .
- name: Typage
run: uv run mypy enervision_ml tests
# Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
- name: Tests
run: uv run pytest
sast:
name: Analyse statique de sécurité
runs-on: ubuntu-latest
defaults:
run:
working-directory: ml
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@v7
- name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
- name: Rapport complet, tous niveaux
continue-on-error: true
run: uvx bandit==1.9.4 --recursive enervision_ml
+172
View File
@@ -0,0 +1,172 @@
name: SonarQube
on:
push:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
# Build l'ensemble du projet, puis lance les tests
# Génère les rapports de couverture, puis lance l'analyse SonarQube
jobs:
build-front:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test-front:
needs: build-front
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v7
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
build-back:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: apps/backend
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: apps/backend
- name: Vérifie le formatage
run: uv run ruff format --check .
working-directory: apps/backend
- name: Analyse statique
run: uv run ruff check --output-format=github .
working-directory: apps/backend
- name: Typage
run: uv run mypy app
working-directory: apps/backend
test-back:
needs: build-back
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name : Lancement des tests et génénration du rapport de couverture (Back)
run: uv run pytest --cov-fail-under=85 --cov-report=xml
working-directory: apps/backend
- name: Upload coverage
uses: actions/upload-artifact@v7
with:
name: backend-coverage
path: apps/backend/coverage.xml
test-ml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: ml
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: ml
- name: Lancement des tests et génération du rapport de couverture (ML)
run: uv run pytest --cov-report=xml
working-directory: ml
- name: Upload coverage
uses: actions/upload-artifact@v7
with:
name: ml-coverage
path: ml/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back, test-ml]
name: SonarQube
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v8
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v8
with:
name: backend-coverage
path: apps/backend
- name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v8
with:
name: ml-coverage
path: ml
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+16 -1
View File
@@ -52,11 +52,26 @@ standalone_admin_password.txt
secrets/
# Donnees locales
data/
data/raw/*
!data/raw/.gitkeep
*.sqlite3
monitoring/grafana/data/
monitoring/prometheus/data/
# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux
ml/data/
ml/models/*
!ml/models/.gitkeep
ml/mlruns/
ml/mlartifacts/
ml/mlflow.db
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
etl/airflow/tests/.airflow_home/
# TLS : certificats du reverse proxy, générés par script ou par certbot
infra/proxy/tls/*.pem
# IDE et OS
.idea/
.vscode/
+128 -12
View File
@@ -1,49 +1,162 @@
BACKEND := apps/backend
FRONTEND := apps/frontend
ML := ml
AIRFLOW := etl/airflow
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose.
# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`.
PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1)
PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local)
export PUBLIC_HOST
ifdef ACME_EMAIL
export ACME_EMAIL
endif
.DEFAULT_GOAL := help
.PHONY: help install dev lint format typecheck test test-cov test-integration check \
docker-build db-up db-down db-reset db-logs db-psql migrate
.PHONY: help install install-backend install-frontend install-ml install-airflow \
dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
help: ## Liste les cibles disponibles
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
install: ## Installe les dependances du backend
install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow
install-backend: ## Installe les dépendances du backend
cd $(BACKEND) && uv sync --all-groups
dev: ## Lance l'API en rechargement a chaud
install-frontend: ## Installe les dépendances du frontend
cd $(FRONTEND) && npm ci
install-ml: ## Installe les dépendances du pipeline ML
cd $(ML) && uv sync --all-groups
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
cd $(AIRFLOW) && uv sync --all-groups
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
@trap 'kill 0' EXIT INT TERM; \
$(MAKE) --no-print-directory dev-backend & \
$(MAKE) --no-print-directory dev-frontend & \
wait
dev-backend: ## Lance l'API seule en rechargement à chaud
@echo "backend -> http://localhost:8000 (docs sur /docs)"
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
dev-frontend: ## Lance le frontend seul en rechargement à chaud
@echo "frontend -> http://localhost:4200"
cd $(FRONTEND) && npm start
lint: ## Analyse statique du backend
cd $(BACKEND) && uv run ruff check .
format: ## Formate et corrige le backend
cd $(BACKEND) && uv run ruff format . && uv run ruff check --fix .
typecheck: ## Verifie le typage du backend
typecheck: ## Vérifie le typage du backend
cd $(BACKEND) && uv run mypy app
test: ## Execute les tests backend ne demandant pas de base
test: ## Exécute les tests backend ne demandant pas de base
cd $(BACKEND) && uv run pytest --cov-fail-under=85
test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit
test-cov: ## Rapports de couverture HTML et XML, plus les résultats au format JUnit
cd $(BACKEND) && uv run pytest --cov-fail-under=85 --cov-report=html \
--cov-report=xml --junitxml=test-results/junit.xml
test-integration: ## Execute les tests exigeant une base joignable
test-integration: ## Exécute les tests exigeant une base joignable
cd $(BACKEND) && uv run pytest -m integration
check: lint typecheck test ## Chaine de verification complete
check: lint typecheck test ## Chaîne de vérification complète
openapi: ## Régénère apps/backend/openapi.json depuis les routes déclarées
cd $(BACKEND) && uv run python -m app.cli export-openapi
ml-lint: ## Analyse statique du pipeline ML
cd $(ML) && uv run ruff check .
ml-typecheck: ## Vérifie le typage du pipeline ML
cd $(ML) && uv run mypy enervision_ml tests
ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base ni serveur MLflow)
cd $(ML) && uv run pytest
ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel
cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),)
airflow-lint: ## Analyse statique des DAGs Airflow
cd $(AIRFLOW) && uv run ruff check .
airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structure attendue
cd $(AIRFLOW) && uv run pytest
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-webserver airflow-scheduler
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
airflow-down: ## Arrête le webserver et le scheduler Airflow
docker compose stop airflow-webserver airflow-scheduler
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
docker compose logs -f airflow-scheduler
docker-build: ## Construit l'image du backend
docker build -t enervision-backend:local $(BACKEND)
db-up: ## Demarre la base PostgreSQL TimescaleDB
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin
@test -f infra/proxy/tls/fullchain.pem \
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
$(COMPOSE_PROD) up -d --build
stack-down: ## Arrête la stack complète en conservant les données
$(COMPOSE_PROD) stop
stack-logs: ## Suit les journaux du reverse proxy
$(COMPOSE_PROD) logs -f proxy
tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis
@test "$(PUBLIC_HOST)" != enervision.local \
|| { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; }
$(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \
-d $(PUBLIC_HOST) \
--email $${ACME_EMAIL:?ACME_EMAIL=... requis} \
--agree-tos --no-eff-email --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
db-up: ## Démarre la base PostgreSQL TimescaleDB
docker compose up -d db
db-down: ## Arrete la base en conservant ses donnees
db-down: ## Arrête la base en conservant ses données
docker compose stop db
db-reset: ## Detruit la base et rejoue db/init
db-reset: ## Détruit la base et rejoue db/init
docker compose down -v && docker compose up -d db
db-logs: ## Suit les journaux de la base
@@ -54,3 +167,6 @@ db-psql: ## Ouvre une session psql sur la base applicative
migrate: ## Applique les migrations Alembic
cd $(BACKEND) && uv run alembic upgrade head
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
+57 -15
View File
@@ -3,20 +3,40 @@
Monorepo de la plateforme EnerVision : collecte, stockage, analyse et restitution de
series temporelles energetiques, deployee sur une machine on-premise.
## Jalons
| Jalon | Intitulé |
|-------|----------------------------------------------------------|
| J1 | Valider la préparation de l'environnement et du repo |
| J2 | Valider le périmètre retenu et les choix technologiques |
| J3 | Ingestion & backend |
| J4 | Architecture, sécurité & frontend |
| J5 | Valider la robustesse et assurer les livrables |
| J6 | Amélioration possible |
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
## Stack cible
| Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
| Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| CI/CD | GitHub Actions | `.github/workflows` | A initialiser |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | En place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | En place |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Les autres dossiers
portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README
de cadrage, leur contenu fait l'objet d'un ticket dedie.
L'etat detaille de chaque brique et les vues d'architecture sont dans
[docs/architecture](docs/architecture/README.md).
## Arborescence
@@ -30,13 +50,16 @@ portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un t
│ ├── migrations/ Migrations SQL versionnees
│ └── seeds/ Jeux de donnees de reference
├── etl/airflow/
│ ├── dags/ DAGs d'ingestion et d'agregation
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes)
│ ├── plugins/ Operateurs et hooks maison
│ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs
├── infra/terraform/
├── infra/
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
│ └── terraform/
│ ├── modules/ Modules reutilisables
│ └── environments/ Racines Terraform, une par environnement
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
├── monitoring/
│ ├── prometheus/ Collecte et regles d'alerte
│ ├── grafana/ Provisioning et dashboards
@@ -47,16 +70,17 @@ portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un t
## Demarrage
Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul.
Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que
`uv` installe seul.
```bash
cp .env.example .env # variables de docker-compose
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
make install # dependances du backend
make install # dependances du backend et du frontend
make migrate # applique les migrations Alembic
make dev # API sur http://localhost:8000, docs sur /docs
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
make check # lint + typage + tests
```
@@ -67,9 +91,11 @@ Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker
5432, souvent deja pris par une autre base.
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
et n'embarque pas le source, donc toute modification y demande un
`docker compose up -d --build backend`.
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend`
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc
toute modification y demande un `docker compose up -d --build backend`.
Verifier que la base repond et que l'extension est chargee :
@@ -77,8 +103,24 @@ Verifier que la base repond et que l'extension est chargee :
curl -s localhost:8000/api/v1/health/ready
```
## Stack complète derrière le reverse proxy
Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine.
L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** :
```bash
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié
```
Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun
nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
## Conventions
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
- Commits : Conventional Commits, portee = dossier de premier niveau concerne.
- Toute decision structurante donne lieu a un ADR dans `docs/adr`.
- Toute PR qui change un composant met a jour sa vue dans `docs/architecture`, dans la meme PR.
+19 -1
View File
@@ -1,6 +1,24 @@
APP_ENV=local
APP_DEBUG=true
APP_DEBUG=false
APP_LOG_LEVEL=INFO
# L'API refuse de démarrer tant que cette valeur reste un exemple ou fait moins de
# 32 caractères. Générer la vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut.
APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password
# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS.
# À remplacer par un vrai relais en staging/prod.
APP_SMTP_HOST=localhost
APP_SMTP_PORT=1025
APP_SMTP_USE_TLS=false
APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10
+5 -4
View File
@@ -11,13 +11,14 @@ WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
uv sync --locked --no-install-project --no-dev --no-build
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
FROM python:3.14-slim AS runtime
+58 -11
View File
@@ -28,7 +28,7 @@ de demarrer sans elles.
## Commandes
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
`make format`, `make typecheck`, `make test`, `make check`, `make docker-build`.
`make format`, `make typecheck`, `make test`, `make check`, `make openapi`, `make docker-build`.
Directement depuis ce dossier :
@@ -39,8 +39,12 @@ uv run ruff format . # format
uv run mypy app # typage strict
uv run pytest # tests + couverture
uv run pytest -m integration # tests exigeant une base joignable
uv run python -m app.cli export-openapi # régénère openapi.json
```
`openapi.json` est versionné : `tests/api/test_openapi.py` échoue si le fichier ne correspond
plus aux routes déclarées. Toute PR qui change une route le régénère dans le même commit.
Les conventions de tests, les gabarits et le detail des marqueurs sont dans
[`TESTING.md`](TESTING.md).
@@ -57,13 +61,21 @@ independants de l'environnement.
```
app/
├── api/
│ ├── deps.py Dependances FastAPI partagees (session, settings)
│ ├── deps.py Dépendances partagées : session, settings, principal, gardes de rôle
│ ├── errors.py Gestionnaires 422 et 500
│ ├── middleware.py En-têtes de sécurité
│ ├── security.py Garde du point /metrics
│ └── v1/
│ ├── router.py Agregation des routes de la version 1
│ └── endpoints/ Un module par ressource exposee
│ ├── router.py Agrégation des routes de la version 1
│ └── endpoints/ Un module par ressource exposée
├── core/
│ ├── config.py Settings Pydantic, source unique de configuration
│ └── logging.py Journalisation console en local, JSON en production
│ ├── cookies.py Attributs du cookie de rafraîchissement
│ ├── hashing.py Argon2id, poussé dans un fil sous limiteur
│ ├── logging.py Journalisation console en local, JSON en production
│ ├── principal.py L'identité que voit le code métier
│ ├── roles.py Rôles ordonnés
│ └── security.py Encodage et décodage des jetons d'accès
├── db/
│ ├── base.py Base declarative SQLAlchemy
│ └── session.py Engine et sessions asynchrones
@@ -71,6 +83,7 @@ app/
├── schemas/ Modeles Pydantic d'entree et de sortie
├── repositories/ Acces aux donnees, une classe par agregat
├── services/ Regles metier, orchestrent les repositories
├── cli.py Commandes hors HTTP, dont l'amorcage du premier admin
└── main.py Factory applicative
tests/ Miroir de app/
alembic/ Migrations du schema applicatif
@@ -81,12 +94,46 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
## Routes
| Route | Role |
|------------------------|-------------------------------------------------|
| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe |
| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB |
| `/metrics` | Metriques au format Prometheus |
| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` |
| Route | Rôle | Accès |
|---|---|---|
| `/api/v1/health/live` | Sonde de vivacité, aucune dépendance externe | public |
| `/api/v1/health/ready` | Sonde de disponibilité, vérifie la base et TimescaleDB | public |
| `/api/v1/auth/login` | Ouvre une session | public |
| `/api/v1/auth/refresh` | Fait tourner la session | cookie |
| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente |
| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton |
| `/api/v1/auth/password` | Change son propre mot de passe | jeton |
| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public |
| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public |
| `/api/v1/auth/me` | Décrit le compte connecté | jeton |
| `/api/v1/users` | Liste et crée des comptes | `admin` |
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` |
| `/api/v1/sites` | Liste les sites | `lecteur` |
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `lecteur` |
| `/api/v1/recommendations/generate` | Génère les recommandations depuis les alertes (POST) | `admin` |
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
Le contrat détaillé pour le frontend est dans
[`docs/architecture/31-contrat-authentification.md`](../../docs/architecture/31-contrat-authentification.md).
## Premier administrateur
Aucun compte n'existe après les migrations. Il s'en crée un en ligne de commande :
```bash
make bootstrap-admin EMAIL=prenom.nom@enervision.fr # mot de passe saisi au clavier
# ou, depuis apps/backend :
uv run python -m app.cli create-admin --email prenom.nom@enervision.fr --generate
```
Le compte est créé avec `must_change_password`, donc la première connexion ne donne accès qu'à
`/auth/me` et `/auth/password` jusqu'au changement. Le mot de passe ne transite jamais par
`argv`, visible de tout `ps`, et aucune révision Alembic n'insère de compte : son empreinte
resterait dans Git pour toujours.
## Migrations
+50
View File
@@ -123,6 +123,11 @@ async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> Non
defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre
declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus.
Ces tests ne sont pas pour autant facultatifs : le job `integration` de
`.github/workflows/backend.yml` monte un service TimescaleDB, applique les migrations et
les joue a chaque poussee. Un test `integration` casse donc la CI comme un autre. En local,
`make db-up` puis `make test-integration`.
## Couverture
Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique
@@ -141,3 +146,48 @@ make check # lint + typage + suite unitaire
uv run pytest tests/api/test_health.py # un seul fichier
uv run pytest -k readiness # par motif de nom
```
## Quatre fichiers à connaître avant de toucher à l'authentification
`tests/api/acces.py` porte la classification des routes du contrat, en quatre ensembles :
`ROUTES_PUBLIQUES`, `ROUTE_COOKIE`, `ROUTES_SANS_ROLE` et la table `ROLE_MINIMUM`. Ce n'est pas
un fichier de test, c'est la référence que les trois autres confrontent au comportement observé.
**Toute route ajoutée doit y être classée** : `test_every_declared_route_is_classified` échoue
sinon, et échoue aussi sur une entrée qui ne correspond plus à aucune route.
`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de
dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route
peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose
donc de modifier `ROUTES_PUBLIQUES` dans `acces.py`**, ce qui apparaît en clair dans la diff
d'une pull request.
`tests/api/test_matrice_acces.py` croise chaque route gardée avec chacun des trois rôles, dans
les deux sens : un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le
reçoit jamais. Le second sens est ce qui rend visible une garde posée trop haut, par exemple
`AdminDep` sur une route de lecture. La même matrice est rejouée sous `integration` avec de vrais
jetons, donc en traversant le décodage du JWT et la relecture du compte en base, que
`dependency_overrides` court-circuite.
`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend
possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test
n'atteint :
- adresse inconnue → le compteur vaut 1, donc le haché leurre a bien été vérifié et il n'y a pas
d'oracle temporel ;
- limite de débit atteinte → le compteur vaut 0, donc la limite est évaluée avant Argon2.
`tests/api/test_parcours_authentification.py` joue six parcours complets contre la vraie base,
sous le marqueur `integration`, sans serveur ni port ouvert. C'est là que se démontrent
l'atomicité de la rotation, la mort de la famille au rejeu d'un cookie déjà tourné, et la
révocation immédiate d'un compte désactivé.
## Deux pièges d'écriture de test
**Lire les attributs avant le `rollback`.** Un `session.rollback()` périme les attributs chargés,
et les relire déclenche une entrée-sortie hors du contexte greenlet, donc un `MissingGreenlet`.
On capture la valeur dans une variable locale avant d'annuler.
**`audit_log` ne se nettoie pas.** La table est en ajout seul, garanti par déclencheur : un test
ne peut pas effacer ce qu'il y écrit, et les lignes d'une exécution précédente sont encore là.
Chaque test filtre donc sur son propre `target_id` plutôt que de supposer une table vide.
+1 -1
View File
@@ -19,7 +19,7 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%"))
target_metadata = Base.metadata
@@ -0,0 +1,119 @@
"""tentatives de connexion et journal d audit
Revision ID: 517053a3c044
Revises: b1a7c3d9e240
Create Date: 2026-09-15 14:31:07.966180
Deux tables aux vocations opposees. `login_attempt` est le compteur de la limitation
de debit : son volume est pilote par l'attaquant, donc elle se purge. `audit_log` est
en ajout seul, garanti par deux declencheurs.
Le declencheur TRUNCATE n'est pas redondant : TRUNCATE ne passe pas par les
declencheurs de ligne. Et RAISE EXCEPTION plutot qu'un RETURN NULL, qui annulerait
l'operation silencieusement.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "517053a3c044"
down_revision: str | Sequence[str] | None = "b1a7c3d9e240"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
FONCTION_AJOUT_SEUL = """
CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log est en ajout seul : % interdit', TG_OP;
END
$$ LANGUAGE plpgsql;
"""
DECLENCHEUR_LIGNE = """
CREATE TRIGGER audit_log_no_update_delete
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
"""
DECLENCHEUR_TRUNCATE = """
CREATE TRIGGER audit_log_no_truncate
BEFORE TRUNCATE ON audit_log
FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only();
"""
def upgrade() -> None:
op.create_table(
"login_attempt",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("email_tried", sa.String(length=320), nullable=False),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("outcome", sa.Text(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=True),
sa.CheckConstraint(
"outcome in ('success', 'bad_credentials', 'throttled', 'inactive')",
name="ck_login_attempt_outcome",
),
sa.PrimaryKeyConstraint("id", name="pk_login_attempt"),
)
op.create_index(
"ix_login_attempt_email_date", "login_attempt", ["email_tried", "occurred_at"]
)
op.create_index("ix_login_attempt_ip_date", "login_attempt", ["client_ip", "occurred_at"])
op.create_table(
"audit_log",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("actor_id", sa.UUID(), nullable=True),
sa.Column("actor_email", sa.Text(), nullable=True),
sa.Column("actor_role", sa.Text(), nullable=True),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("target_type", sa.Text(), nullable=True),
sa.Column("target_id", sa.Text(), nullable=True),
sa.Column("outcome", sa.Text(), nullable=False),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column(
"detail",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("jsonb_build_object()"),
nullable=False,
),
sa.CheckConstraint("outcome in ('success', 'failure')", name="ck_audit_log_outcome"),
sa.PrimaryKeyConstraint("id", name="pk_audit_log"),
)
op.create_index("ix_audit_log_date", "audit_log", ["occurred_at"])
op.create_index("ix_audit_log_action_date", "audit_log", ["action", "occurred_at"])
op.execute(FONCTION_AJOUT_SEUL)
op.execute(DECLENCHEUR_LIGNE)
op.execute(DECLENCHEUR_TRUNCATE)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS audit_log_no_truncate ON audit_log;")
op.execute("DROP TRIGGER IF EXISTS audit_log_no_update_delete ON audit_log;")
op.execute("DROP FUNCTION IF EXISTS audit_log_append_only();")
op.drop_index("ix_audit_log_action_date", table_name="audit_log")
op.drop_index("ix_audit_log_date", table_name="audit_log")
op.drop_table("audit_log")
op.drop_index("ix_login_attempt_ip_date", table_name="login_attempt")
op.drop_index("ix_login_attempt_email_date", table_name="login_attempt")
op.drop_table("login_attempt")
@@ -0,0 +1,77 @@
"""jetons de rafraichissement
Revision ID: 821f71be74c0
Revises: 517053a3c044
Create Date: 2026-09-15 14:42:09.757949
Le jeton lui-meme n'est jamais stocke : seule son empreinte SHA-256 l'est, dans
`token_hash`. Un pg_dump qui fuiterait ne livrerait donc aucune session utilisable.
L'index partiel `ix_refresh_token_vivants` sert la revocation en cascade et la
recherche des sessions actives, qui ne regardent jamais les lignes deja tournees.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "821f71be74c0"
down_revision: str | Sequence[str] | None = "517053a3c044"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
MOTIFS = "'logout', 'rotation', 'reuse_detected', 'password_change', 'admin'"
JETONS_VIVANTS = "revoked_at is null and rotated_at is null"
def upgrade() -> None:
op.create_table(
"refresh_token",
sa.Column(
"id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column("family_id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("token_hash", sa.LargeBinary(), nullable=False),
sa.Column(
"issued_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_reason", sa.Text(), nullable=True),
sa.Column("replaced_by", sa.UUID(), nullable=True),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.CheckConstraint(
f"revoked_reason is null or revoked_reason in ({MOTIFS})",
name="ck_refresh_token_revoked_reason",
),
sa.ForeignKeyConstraint(
["user_id"], ["app_user.id"], name="fk_refresh_token_user", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name="pk_refresh_token"),
sa.UniqueConstraint("token_hash", name="uq_refresh_token_hash"),
)
op.create_index("ix_refresh_token_family", "refresh_token", ["family_id"])
op.create_index("ix_refresh_token_user", "refresh_token", ["user_id"])
op.create_index(
"ix_refresh_token_vivants",
"refresh_token",
["user_id"],
postgresql_where=JETONS_VIVANTS,
)
def downgrade() -> None:
op.drop_index(
"ix_refresh_token_vivants", table_name="refresh_token", postgresql_where=JETONS_VIVANTS
)
op.drop_index("ix_refresh_token_user", table_name="refresh_token")
op.drop_index("ix_refresh_token_family", table_name="refresh_token")
op.drop_table("refresh_token")
@@ -0,0 +1,72 @@
"""comptes applicatifs
Revision ID: b1a7c3d9e240
Revises: 5353c0e4f094
Create Date: 2026-09-15 14:40:00.000000
Cree `app_user`, la table des comptes humains et de service. Le nom evite `user`,
mot reserve de PostgreSQL. `gen_random_uuid()` est au coeur de PG17, aucune
extension n'est necessaire.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "b1a7c3d9e240"
down_revision: str | Sequence[str] | None = "5353c0e4f094"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"app_user",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
server_default=sa.text("gen_random_uuid()"),
nullable=False,
),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("role", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), server_default=sa.text("'human'"), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column(
"must_change_password", sa.Boolean(), server_default=sa.text("false"), nullable=False
),
sa.Column(
"credentials_changed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("full_name", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"),
sa.CheckConstraint(
"role in ('lecteur', 'operateur', 'admin')", name="ck_app_user_role"
),
sa.CheckConstraint("kind in ('human', 'service')", name="ck_app_user_kind"),
sa.PrimaryKeyConstraint("id", name="pk_app_user"),
sa.UniqueConstraint("email", name="uq_app_user_email"),
)
def downgrade() -> None:
op.drop_table("app_user")
@@ -0,0 +1,96 @@
"""jetons et tentatives de reinitialisation de mot de passe
Revision ID: c0adab96238c
Revises: e6d2026091501
Create Date: 2026-09-17 10:37:12.571314
Meme schema que `refresh_token` pour `password_reset_token` : seule l'empreinte SHA-256 du
jeton est stockee, jamais le jeton lui-meme, pour la meme raison (revocation en cascade,
aucune session utilisable dans un pg_dump qui fuiterait).
`password_reset_attempt` vit hors de `audit_log`, comme `login_attempt`, car son volume est
pilote par l'attaquant : une campagne de demandes y ecrirait des lignes que l'audit, en ajout
seul, ne devrait jamais purger.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "c0adab96238c"
down_revision: str | Sequence[str] | None = "e6d2026091501"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
JETONS_VIVANTS = "consumed_at is null"
def upgrade() -> None:
op.create_table(
"password_reset_attempt",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("email_tried", sa.String(length=320), nullable=False),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_password_reset_attempt"),
)
op.create_index(
"ix_password_reset_attempt_email_date",
"password_reset_attempt",
["email_tried", "occurred_at"],
)
op.create_index(
"ix_password_reset_attempt_ip_date", "password_reset_attempt", ["client_ip", "occurred_at"]
)
op.create_table(
"password_reset_token",
sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("token_hash", sa.LargeBinary(), nullable=False),
sa.Column(
"issued_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["app_user.id"],
name="fk_password_reset_token_user",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_password_reset_token"),
sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"),
)
op.create_index("ix_password_reset_token_user", "password_reset_token", ["user_id"])
op.create_index(
"ix_password_reset_token_vivants",
"password_reset_token",
["user_id"],
postgresql_where=JETONS_VIVANTS,
)
def downgrade() -> None:
op.drop_index(
"ix_password_reset_token_vivants",
table_name="password_reset_token",
postgresql_where=JETONS_VIVANTS,
)
op.drop_index("ix_password_reset_token_user", table_name="password_reset_token")
op.drop_table("password_reset_token")
op.drop_index("ix_password_reset_attempt_ip_date", table_name="password_reset_attempt")
op.drop_index("ix_password_reset_attempt_email_date", table_name="password_reset_attempt")
op.drop_table("password_reset_attempt")
@@ -0,0 +1,218 @@
"""Création des six tables Data et de l'hypertable reading.
Revision ID: e6d2026091501
Revises: 821f71be74c0
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "e6d2026091501"
down_revision = "821f71be74c0"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"dataset",
sa.Column("dataset_id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("dataset_name", sa.Text(), nullable=False),
sa.Column("archive_sha256", sa.String(length=64), nullable=False),
sa.Column("storage_uri", sa.Text(), nullable=False),
sa.Column("source_timezone", sa.Text(), nullable=True),
sa.Column(
"metadata", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False
),
sa.CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"),
sa.PrimaryKeyConstraint("dataset_id"),
sa.UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"),
)
op.create_table(
"site",
sa.Column("site_id", sa.Text(), nullable=False),
sa.Column("site_name", sa.Text(), nullable=False),
sa.Column("site_type", sa.Text(), nullable=False),
sa.Column("location", sa.Text(), nullable=True),
sa.Column("capacity_kw", sa.Double(), nullable=True),
sa.Column("status", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("site_id"),
)
op.create_table(
"prediction",
sa.Column("prediction_id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("site_id", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("target_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("target_metric", sa.Text(), nullable=False),
sa.Column("period_minutes", sa.Integer(), nullable=True),
sa.Column("predicted_value", sa.Double(), nullable=True),
sa.Column("model_reference", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("failure_reason", sa.Text(), nullable=True),
sa.CheckConstraint(
"(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR (status IN ('insufficient_data', 'error') AND predicted_value IS NULL AND failure_reason IS NOT NULL)",
name="ck_prediction_status",
),
sa.CheckConstraint(
"target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL",
name="ck_prediction_energy_period",
),
sa.CheckConstraint(
"target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_prediction_metric"
),
sa.CheckConstraint(
"period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period"
),
sa.ForeignKeyConstraint(
["site_id"], ["site.site_id"], name="fk_prediction_site", ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("prediction_id"),
sa.UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"),
)
op.create_index(
"ix_prediction_site_target", "prediction", ["site_id", "target_at"], unique=False
)
op.create_table(
"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.Column("source", sa.Text(), nullable=False),
sa.Column("dataset_id", sa.BigInteger(), nullable=True),
sa.Column("consumption_kw", sa.Double(), nullable=True),
sa.Column("consumption_kwh", sa.Double(), nullable=True),
sa.Column("consumption_euros", sa.Numeric(precision=14, scale=2), nullable=True),
sa.Column("voltage_v", sa.Double(), nullable=True),
sa.Column("current_a", sa.Double(), nullable=True),
sa.Column("power_factor", sa.Double(), nullable=True),
sa.Column("temperature_celsius", sa.Double(), nullable=True),
sa.Column("humidity_percent", sa.Double(), nullable=True),
sa.Column("solar_irradiance_wm2", sa.Double(), nullable=True),
sa.Column("is_working_hours", sa.Boolean(), nullable=True),
sa.Column("data_quality", sa.Text(), nullable=True),
sa.Column("null_reasons", postgresql.ARRAY(sa.Text()), nullable=True),
sa.Column(
"imputed_values", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=True
),
sa.Column("imputation_method", sa.Text(), nullable=True),
sa.Column(
"ingested_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False
),
sa.CheckConstraint(
"(source = 'csv' AND dataset_id IS NOT NULL) OR (source IN ('api_current', 'api_history') AND dataset_id IS NULL)",
name="ck_reading_dataset_source",
),
sa.CheckConstraint(
"data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')",
name="ck_reading_quality",
),
sa.CheckConstraint(
"source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source"
),
sa.CheckConstraint(
"(imputed_values IS NULL AND imputation_method IS NULL) OR (imputed_values IS NOT NULL AND imputation_method IS NOT NULL)",
name="ck_reading_imputation",
),
sa.ForeignKeyConstraint(
["dataset_id"], ["dataset.dataset_id"], name="fk_reading_dataset", ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["site_id"], ["site.site_id"], name="fk_reading_site", ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("reading_id", "timestamp"),
)
op.create_index("ix_reading_dataset_id", "reading", ["dataset_id"], unique=False)
op.create_index(
"ix_reading_site_timestamp", "reading", ["site_id", "timestamp"], unique=False
)
op.create_index(
"uq_reading_source",
"reading",
["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")],
unique=True,
)
op.execute(
"SELECT create_hypertable('reading', by_range('timestamp'), create_default_indexes => FALSE)"
)
op.create_table(
"alert",
sa.Column("alert_id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("source_alert_id", sa.Text(), nullable=False),
sa.Column("site_id", sa.Text(), nullable=False),
sa.Column("source", sa.Text(), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.Column("type", sa.Text(), nullable=False),
sa.Column("severity", sa.Text(), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("value", sa.Double(), nullable=True),
sa.Column("threshold", sa.Double(), nullable=True),
sa.Column("metric", sa.Text(), nullable=True),
sa.Column("prediction_id", sa.BigInteger(), nullable=True),
sa.Column(
"raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False
),
sa.CheckConstraint(
"severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity"
),
sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"),
sa.CheckConstraint(
"type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type"
),
sa.ForeignKeyConstraint(
["prediction_id", "site_id"],
["prediction.prediction_id", "prediction.site_id"],
name="fk_alert_prediction_site",
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["site_id"], ["site.site_id"], name="fk_alert_site", ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("alert_id"),
sa.UniqueConstraint(
"source", "site_id", "source_alert_id", name="uq_alert_source_reference"
),
)
op.create_index("ix_alert_site_timestamp", "alert", ["site_id", "timestamp"], unique=False)
op.create_table(
"recommendation",
sa.Column("recommendation_id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("alert_id", sa.BigInteger(), nullable=False),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("explanation", sa.Text(), nullable=False),
sa.Column("rule_reference", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["alert_id"], ["alert.alert_id"], name="fk_recommendation_alert", ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("recommendation_id"),
sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"),
)
# ### end Alembic commands ###
def downgrade() -> None:
op.drop_table("recommendation")
op.drop_table("alert")
op.drop_table("reading")
op.drop_table("prediction")
op.drop_table("site")
op.drop_table("dataset")
+289 -1
View File
@@ -1,10 +1,298 @@
# Piège : `get_current_principal()` relit le compte en base à chaque requête au lieu de faire
# confiance aux claims. C'est le renoncement assumé à la propriété « sans état » : sur un seul
# service et une seule base, elle n'achetait rien, et la lecture par clé primaire coûte moins
# d'un pour cent du budget d'une requête. Ce qu'elle achète, c'est la révocation immédiate.
# Piège : le `Principal` est construit depuis la ligne, jamais depuis le claim `role`. Un claim
# périmé ne peut donc pas provoquer d'élévation de privilège.
from collections.abc import Callable
from datetime import timedelta
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings, get_settings
from app.core.hashing import Argon2Hasher, build_hasher
from app.core.mailer import Mailer, SmtpConfig
from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
from app.core.security import decode_access_token as decode_token
from app.db.session import get_session
from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.prediction import PredictionRepository
from app.repositories.reading import ReadingRepository
from app.repositories.recommendation import RecommendationRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
from app.services.prediction import PredictionService
from app.services.reading import ReadingService
from app.services.recommendation import RecommendationService
from app.services.sensor import SensorService
from app.services.site import SiteService
from app.services.stats import StatsService
from app.services.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)]
SettingsDep = Annotated[Settings, Depends(get_settings)]
CODE_CHANGEMENT_REQUIS = "password_change_required"
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès")
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
def _non_authentifie(description: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
headers={"WWW-Authenticate": f'Bearer error="{description}"'},
)
def get_token_policy(settings: SettingsDep) -> TokenPolicy:
return TokenPolicy(
secret=settings.secret_key.get_secret_value(),
issuer=settings.jwt_issuer,
audience=settings.jwt_audience,
access_ttl=timedelta(seconds=settings.access_token_ttl_seconds),
)
# Construire un `Argon2Hasher` calcule un haché leurre, donc 17 ms : il est mis en cache sur
# les paramètres plutôt que reconstruit à chaque requête.
@lru_cache
def _hasher_cache(
time_cost: int, memory_cost_kib: int, parallelism: int, max_concurrency: int
) -> Argon2Hasher:
return build_hasher(
time_cost=time_cost,
memory_cost_kib=memory_cost_kib,
parallelism=parallelism,
max_concurrency=max_concurrency,
)
def get_hasher(settings: SettingsDep) -> Argon2Hasher:
return _hasher_cache(
settings.argon2_time_cost,
settings.argon2_memory_cost_kib,
settings.argon2_parallelism,
settings.argon2_max_concurrency,
)
def get_client_ip(request: Request, settings: SettingsDep) -> str | None:
# Derrière un proxy, `request.client.host` vaut l'IP du proxy : le compteur par IP
# deviendrait global, donc un déni de service auto-infligé. Le dernier élément est le seul
# qu'un proxy de confiance ait écrit, les précédents sont fournis par le client.
if settings.trust_proxy_headers:
transmis = request.headers.get("x-forwarded-for")
if transmis:
return transmis.split(",")[-1].strip()
return request.client.host if request.client else None
def get_mailer(settings: SettingsDep) -> Mailer:
return Mailer(
SmtpConfig(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=(
settings.smtp_password.get_secret_value() if settings.smtp_password else None
),
use_tls=settings.smtp_use_tls,
from_address=settings.smtp_from_address,
)
)
def get_auth_service(
session: SessionDep,
settings: SettingsDep,
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
mailer: Annotated[Mailer, Depends(get_mailer)],
) -> AuthService:
return AuthService(
users=UserRepository(session),
attempts=LoginAttemptRepository(session),
refresh_tokens=RefreshTokenRepository(session),
audit=AuditLogRepository(session),
hasher=hasher,
transaction=session,
token_policy=token_policy,
login_policy=LoginPolicy(
window_seconds=settings.login_window_seconds,
max_failures_per_identifier_and_ip=(settings.login_max_failures_per_identifier_and_ip),
max_failures_per_ip=settings.login_max_failures_per_ip,
max_failures_per_identifier=settings.login_max_failures_per_identifier,
),
refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds),
reset_tokens=PasswordResetTokenRepository(session),
reset_attempts=PasswordResetAttemptRepository(session),
reset_policy=PasswordResetPolicy(
window_seconds=settings.password_reset_window_seconds,
max_requests_per_identifier=settings.password_reset_max_requests_per_identifier,
max_requests_per_ip=settings.password_reset_max_requests_per_ip,
token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds),
frontend_reset_url=settings.frontend_reset_password_url,
),
mailer=mailer,
)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
def get_user_service(
session: SessionDep,
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
) -> UserService:
return UserService(
users=UserRepository(session),
refresh_tokens=RefreshTokenRepository(session),
audit=AuditLogRepository(session),
hasher=hasher,
transaction=session,
)
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
def get_site_service(session: SessionDep) -> SiteService:
return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session))
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
def get_alert_service(session: SessionDep) -> AlertService:
return AlertService(
alerts=AlertRepository(session),
readings=ReadingRepository(session),
predictions=PredictionRepository(session),
sites=SiteRepository(session),
)
AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
def get_recommendation_service(session: SessionDep) -> RecommendationService:
return RecommendationService(
recommendations=RecommendationRepository(session),
alerts=AlertRepository(session),
transaction=session,
)
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
def get_stats_service(session: SessionDep) -> StatsService:
return StatsService(sites=SiteRepository(session), readings=ReadingRepository(session))
StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)]
def get_reading_service(session: SessionDep) -> ReadingService:
return ReadingService(readings=ReadingRepository(session))
ReadingServiceDep = Annotated[ReadingService, Depends(get_reading_service)]
def get_sensor_service(session: SessionDep) -> SensorService:
return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session))
SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)]
def get_prediction_service(session: SessionDep) -> PredictionService:
return PredictionService(
sites=SiteRepository(session), predictions=PredictionRepository(session)
)
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
) -> Principal:
if credentials is None:
raise _non_authentifie("invalid_request")
try:
claims = decode_token(token_policy, credentials.credentials)
except TokenExpiredError as erreur:
raise _non_authentifie("expired") from erreur
except TokenInvalidError as erreur:
raise _non_authentifie("invalid_token") from erreur
compte = await UserRepository(session).get_by_id(claims.subject)
if compte is None or not compte.is_active:
raise _non_authentifie("invalid_token")
# Piège : `iat` est une date JWT, donc en secondes entières. Comparer sans tronquer le
# marqueur rejetterait tout jeton émis dans la même seconde que le changement, c'est-à-dire
# celui que `/auth/password` vient de rendre pour garder l'appareil courant connecté.
if int(claims.issued_at.timestamp()) < int(compte.credentials_changed_at.timestamp()):
raise _non_authentifie("token_stale")
if claims.role != compte.role:
raise _non_authentifie("token_stale")
return Principal(
id=compte.id,
email=compte.email,
role=Role(compte.role),
kind=AccountKind(compte.kind),
must_change_password=compte.must_change_password,
)
CurrentPrincipalDep = Annotated[Principal, Depends(get_current_principal)]
def require_role(minimum: Role) -> Callable[[Principal], Principal]:
def garde(principal: CurrentPrincipalDep) -> Principal:
if principal.must_change_password:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=CODE_CHANGEMENT_REQUIS
)
if not has_at_least(principal.role, minimum):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Droits insuffisants")
return principal
return garde
LecteurDep = Annotated[Principal, Depends(require_role(Role.LECTEUR))]
OperateurDep = Annotated[Principal, Depends(require_role(Role.OPERATEUR))]
AdminDep = Annotated[Principal, Depends(require_role(Role.ADMIN))]
def require_trusted_origin(request: Request, settings: SettingsDep) -> None:
# Un navigateur envoie toujours `Origin` sur une requête non sûre. Son absence signale un
# client hors navigateur, qui ne détient aucun cookie de victime : rien à protéger.
origine = request.headers.get("origin")
if origine is None:
return
if origine not in settings.allowed_origins:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Origine refusée")
+47
View File
@@ -0,0 +1,47 @@
# Piège : la réponse 422 par défaut de FastAPI contient la clé `input`, c'est-à-dire la valeur
# rejetée. Sur `/auth/login`, un corps malformé renverrait donc le mot de passe au client et le
# déposerait dans les journaux d'erreur. `validation_error_handler()` ne laisse passer que le
# champ fautif et le type d'erreur.
import uuid
from typing import Any
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core.logging import get_logger
logger = get_logger(__name__)
async def validation_error_handler(_: Request, exception: RequestValidationError) -> JSONResponse:
champs: list[dict[str, Any]] = [
{
"champ": ".".join(str(element) for element in erreur["loc"]),
"type": erreur["type"],
}
for erreur in exception.errors()
]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": champs}
)
async def unhandled_error_handler(request: Request, exception: Exception) -> JSONResponse:
correlation = uuid.uuid4().hex
logger.exception(
"erreur non gérée correlation=%s methode=%s chemin=%s",
correlation,
request.method,
request.url.path,
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Erreur interne", "correlation": correlation},
)
def register_error_handlers(application: FastAPI) -> None:
application.add_exception_handler(RequestValidationError, validation_error_handler) # type: ignore[arg-type]
application.add_exception_handler(Exception, unhandled_error_handler)
+35
View File
@@ -0,0 +1,35 @@
# Pourquoi : `SecurityHeadersMiddleware` ne pose ni HSTS ni CSP, et c'est délibéré.
# L'application ignore si TLS termine devant elle, donc elle ne peut pas décider d'un HSTS ;
# et une CSP sur une API JSON ne protège presque rien, celle qui compte protège la page
# Angular. Les deux appartiennent au terminateur TLS.
# Contrainte : `/docs` charge Swagger depuis un CDN, une CSP stricte ici casserait la
# documentation sans rien sécuriser.
from collections.abc import Awaitable, Callable
from typing import Final
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
EN_TETES: Final[dict[str, str]] = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
}
PREFIXE_AUTHENTIFICATION: Final = "/auth"
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
response = await call_next(request)
for nom, valeur in EN_TETES.items():
response.headers.setdefault(nom, valeur)
# Une réponse d'authentification ne doit jamais être conservée par un intermédiaire.
if PREFIXE_AUTHENTIFICATION in request.url.path:
response.headers["Cache-Control"] = "no-store"
return response
+186
View File
@@ -0,0 +1,186 @@
# Piège : `cookie_de_rafraichissement` est purement documentaire, d'où son `auto_error=False`.
# Avec la valeur par défaut, FastAPI répondrait 403 avant d'atteindre `lit_le_cookie()`, et
# `/auth/refresh` cesserait de rendre le 401 que le frontend attend.
from typing import Any, Final
from fastapi.security import APIKeyCookie
from app.core.config import REFRESH_COOKIE_DEFAUT
from app.schemas.errors import ErrorResponse, InternalErrorResponse, ValidationErrorResponse
Reponses = dict[int | str, dict[str, Any]]
SUMMARY: Final = "Collecte, analyse et restitution de séries temporelles énergétiques."
DESCRIPTION: Final = """
Toutes les routes sont préfixées par `/api/v1`.
**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.
Le jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il
suffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un
nouveau jeton d'accès et fait tourner le cookie.
**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du
précédent.
**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut
`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe
provisoire avant toute autre action.
Le parcours de session complet est décrit dans
`docs/architecture/31-contrat-authentification.md`.
"""
TAGS: Final[list[dict[str, Any]]] = [
{
"name": "health",
"description": (
"Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` "
"que la base répond et que l'extension TimescaleDB est chargée."
),
},
{
"name": "auth",
"description": (
"Ouverture, rotation et fermeture de session, et changement de son propre mot de passe."
),
},
{
"name": "users",
"description": "Administration des comptes. Réservé au rôle `admin`.",
},
{
"name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
},
{
"name": "alerts",
"description": "Consultation des alertes de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
{
"name": "recommendations",
"description": (
"Consultation des recommandations issues des alertes. Accessible à partir du rôle "
"`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`."
),
},
{
"name": "stats",
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
{
"name": "readings",
"description": (
"Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, "
"24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du "
"rôle `lecteur`."
),
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
},
{
"name": "predictions",
"description": (
"Dernière prévision de consommation par site, calculée hors ligne par le pipeline "
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
),
},
]
cookie_de_rafraichissement = APIKeyCookie(
name=REFRESH_COOKIE_DEFAUT,
scheme_name="Cookie de rafraîchissement",
description=(
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
"`/api/v1/auth`."
),
auto_error=False,
)
# Le 422 n'est déclaré que sur les routes qui acceptent un corps ou un paramètre : ailleurs,
# aucune validation ne peut échouer et l'annoncer serait faux.
REPONSE_VALIDATION: Final[Reponses] = {
422: {
"model": ValidationErrorResponse,
"description": (
"Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la "
"valeur envoyée."
),
},
}
REPONSE_SERVEUR: Final[Reponses] = {
500: {
"model": InternalErrorResponse,
"description": (
"Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas "
"renvoyée au client."
),
},
}
REPONSE_INDISPONIBLE: Final[Reponses] = {
503: {
"model": ErrorResponse,
"description": "Base injoignable, ou extension TimescaleDB absente de la base.",
},
}
REPONSES_AUTHENTIFIEES: Final[Reponses] = {
401: {
"model": ErrorResponse,
"description": (
"Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une "
"désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`."
),
},
}
REPONSES_ADMIN: Final[Reponses] = {
**REPONSES_AUTHENTIFIEES,
403: {
"model": ErrorResponse,
"description": (
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
"`password_change_required`."
),
},
}
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
# insuffisants, seulement pour le mot de passe provisoire.
REPONSES_LECTEUR: Final[Reponses] = {
**REPONSES_AUTHENTIFIEES,
403: {
"model": ErrorResponse,
"description": (
"Mot de passe provisoire à changer (`detail` vaut `password_change_required`)."
),
},
}
REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
403: {
"model": ErrorResponse,
"description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).",
},
}
REPONSE_LIMITE: Final[Reponses] = {
429: {
"model": ErrorResponse,
"description": "Trop de demandes sur cette fenêtre glissante.",
"headers": {
"Retry-After": {
"description": "Secondes à attendre avant une nouvelle tentative.",
"schema": {"type": "integer"},
}
},
},
}
+23
View File
@@ -0,0 +1,23 @@
# Pourquoi : `/metrics` est protégé par un jeton statique et non par un rôle applicatif. Coupler
# la supervision au modèle d'utilisateurs casserait la collecte à chaque panne
# d'authentification, c'est-à-dire précisément quand on a besoin des métriques. Le vrai contrôle
# reste le réseau : Prometheus scrute sur le réseau interne et `/metrics` ne sort pas.
import secrets
from fastapi import HTTPException, Request, status
from app.api.deps import SettingsDep
def require_metrics_token(request: Request, settings: SettingsDep) -> None:
attendu = settings.metrics_token
if attendu is None:
return
presente = request.headers.get("authorization", "")
prefixe = "Bearer "
if not presente.startswith(prefixe) or not secrets.compare_digest(
presente[len(prefixe) :], attendu.get_secret_value()
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Jeton requis")
@@ -0,0 +1,23 @@
from fastapi import APIRouter
from app.api.deps import AlertServiceDep, LecteurDep
from app.api.openapi import REPONSE_VALIDATION
from app.schemas.alert import AlertResponse, AlertSeverity
router = APIRouter()
@router.get(
"",
response_model=list[AlertResponse],
summary="Liste les alertes",
responses=REPONSE_VALIDATION,
)
async def list_alerts(
_: LecteurDep,
service: AlertServiceDep,
site_id: str | None = None,
severity: AlertSeverity | None = None,
) -> list[AlertResponse]:
alertes = await service.list_all(site_id=site_id, severity=severity)
return [AlertResponse.model_validate(alerte) for alerte in alertes]
+365
View File
@@ -0,0 +1,365 @@
# Piège : le jeton de rafraîchissement ne quitte jamais le cookie httpOnly, et le jeton
# d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de
# ce module : partout ailleurs, le navigateur n'attache rien de lui-même.
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
from app.api.deps import (
AuthServiceDep,
CurrentPrincipalDep,
SettingsDep,
get_client_ip,
require_trusted_origin,
)
from app.api.openapi import (
REPONSE_LIMITE,
REPONSE_ORIGINE_REFUSEE,
REPONSE_VALIDATION,
REPONSES_AUTHENTIFIEES,
Reponses,
cookie_de_rafraichissement,
)
from app.core.cookies import RefreshCookie, cookie_name
from app.core.logging import get_logger
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
PasswordChangeRequest,
PrincipalResponse,
ResetPasswordRequest,
ResetTokenValidationResponse,
TokenResponse,
)
from app.schemas.errors import ErrorResponse
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError,
SessionRejectedError,
)
router = APIRouter()
logger = get_logger(__name__)
DETAIL_IDENTIFIANTS = "Identifiants invalides"
DETAIL_SESSION = "Session invalide"
DETAIL_LIEN_RESET = "Lien invalide ou expiré"
REPONSES_LOGIN: Reponses = {
**REPONSE_VALIDATION,
401: {
"model": ErrorResponse,
"description": (
"Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans "
"les trois cas, et n'apprend donc rien sur l'existence du compte."
),
},
429: {
"model": ErrorResponse,
"description": "Trop de tentatives sur cette fenêtre glissante.",
"headers": {
"Retry-After": {
"description": "Secondes à attendre avant une nouvelle tentative.",
"schema": {"type": "integer"},
}
},
},
}
REPONSES_REFRESH: Reponses = {
**REPONSE_ORIGINE_REFUSEE,
401: {
"model": ErrorResponse,
"description": (
"Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas "
"toute la famille de sessions est révoquée et le cookie est effacé avec la réponse."
),
},
}
REPONSES_LOGOUT: Reponses = {**REPONSE_ORIGINE_REFUSEE}
REPONSES_LOGOUT_ALL: Reponses = {**REPONSES_AUTHENTIFIEES, **REPONSE_ORIGINE_REFUSEE}
REPONSES_MOT_DE_PASSE: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_ORIGINE_REFUSEE,
401: {
"model": ErrorResponse,
"description": "Jeton d'accès invalide, ou mot de passe courant faux.",
},
}
REPONSES_FORGOT_PASSWORD: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_LIMITE,
}
REPONSES_RESET_PASSWORD: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_ORIGINE_REFUSEE,
400: {
"model": ErrorResponse,
"description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).",
},
}
def repond(
response: Response, settings: SettingsDep, session: AuthenticatedSession
) -> TokenResponse:
response.headers["Cache-Control"] = "no-store"
response.set_cookie(**RefreshCookie.build(settings, session.refresh_secret).as_kwargs())
return TokenResponse(
access_token=session.access_token,
expires_in=session.expires_in,
principal=PrincipalResponse.from_principal(session.principal),
)
# Piège : une `HTTPException` construit sa propre réponse, donc tout en-tête posé sur la
# `Response` injectée est perdu. L'effacement du cookie doit voyager avec l'exception,
# sans quoi un navigateur garderait un cookie mort après une détection de réutilisation.
def entete_de_suppression(settings: SettingsDep) -> str:
temoin = Response()
temoin.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
return temoin.headers["set-cookie"]
def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
secret = request.cookies.get(cookie_name(settings))
if not secret:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_SESSION)
return secret
@router.post(
"/login",
response_model=TokenResponse,
summary="Ouvre une session",
responses=REPONSES_LOGIN,
)
async def login(
payload: LoginRequest,
request: Request,
response: Response,
settings: SettingsDep,
service: AuthServiceDep,
client_ip: str | None = Depends(get_client_ip),
) -> TokenResponse:
response.headers["Cache-Control"] = "no-store"
agent = request.headers.get("user-agent")
try:
session = await service.authenticate(
email=payload.email, password=payload.password, client_ip=client_ip, user_agent=agent
)
except RateLimitedError as erreur:
logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Trop de tentatives, réessayez plus tard",
headers={"Retry-After": str(erreur.retry_after)},
) from erreur
except InvalidCredentialsError as erreur:
logger.warning("auth.login.failure email=%s ip=%s", payload.email, client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS
) from erreur
logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip)
return repond(response, settings, session)
@router.post(
"/refresh",
response_model=TokenResponse,
summary="Fait tourner la session",
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
responses=REPONSES_REFRESH,
)
async def refresh(
request: Request,
response: Response,
settings: SettingsDep,
service: AuthServiceDep,
client_ip: str | None = Depends(get_client_ip),
) -> TokenResponse:
response.headers["Cache-Control"] = "no-store"
try:
session = await service.refresh(
secret=lit_le_cookie(request, settings),
client_ip=client_ip,
user_agent=request.headers.get("user-agent"),
)
except SessionRejectedError as erreur:
logger.warning("auth.refresh.rejected ip=%s", client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=DETAIL_SESSION,
headers={
"Set-Cookie": entete_de_suppression(settings),
"Cache-Control": "no-store",
},
) from erreur
return repond(response, settings, session)
@router.post(
"/logout",
status_code=status.HTTP_204_NO_CONTENT,
summary="Ferme la session courante",
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
responses=REPONSES_LOGOUT,
)
async def logout(
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
) -> None:
response.headers["Cache-Control"] = "no-store"
secret = request.cookies.get(cookie_name(settings))
if secret:
await service.logout(secret=secret)
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
@router.post(
"/logout-all",
status_code=status.HTTP_204_NO_CONTENT,
summary="Ferme toutes les sessions du compte",
dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_LOGOUT_ALL,
)
async def logout_all(
principal: CurrentPrincipalDep,
response: Response,
settings: SettingsDep,
service: AuthServiceDep,
) -> None:
response.headers["Cache-Control"] = "no-store"
revoquees = await service.logout_all(principal)
logger.info("auth.logout_all user_id=%s sessions=%s", principal.id, revoquees)
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
@router.get(
"/me",
response_model=PrincipalResponse,
summary="Décrit le compte connecté",
responses=REPONSES_AUTHENTIFIEES,
)
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
return PrincipalResponse.from_principal(principal)
@router.post(
"/password",
response_model=TokenResponse,
summary="Change son propre mot de passe",
dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_MOT_DE_PASSE,
)
async def change_password(
payload: PasswordChangeRequest,
principal: CurrentPrincipalDep,
request: Request,
response: Response,
settings: SettingsDep,
service: AuthServiceDep,
client_ip: str | None = Depends(get_client_ip),
) -> TokenResponse:
response.headers["Cache-Control"] = "no-store"
try:
session = await service.change_password(
principal=principal,
current_password=payload.current_password,
new_password=payload.new_password,
client_ip=client_ip,
user_agent=request.headers.get("user-agent"),
)
except InvalidCredentialsError as erreur:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS
) from erreur
logger.info("auth.password_changed user_id=%s", principal.id)
return repond(response, settings, session)
@router.post(
"/forgot-password",
status_code=status.HTTP_202_ACCEPTED,
summary="Demande un lien de réinitialisation par email",
responses=REPONSES_FORGOT_PASSWORD,
)
async def forgot_password(
payload: ForgotPasswordRequest,
request: Request,
response: Response,
service: AuthServiceDep,
background_tasks: BackgroundTasks,
client_ip: str | None = Depends(get_client_ip),
) -> None:
response.headers["Cache-Control"] = "no-store"
try:
await service.request_password_reset(
email=payload.email,
client_ip=client_ip,
user_agent=request.headers.get("user-agent"),
background_tasks=background_tasks,
)
except RateLimitedError as erreur:
logger.warning("auth.password_reset.rate_limited ip=%s", client_ip)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Trop de demandes, réessayez plus tard",
headers={"Retry-After": str(erreur.retry_after)},
) from erreur
@router.get(
"/reset-password/validate",
response_model=ResetTokenValidationResponse,
summary="Vérifie sans le consommer si un lien de réinitialisation est encore valide",
responses=REPONSE_VALIDATION,
)
async def validate_reset_token(token: str, service: AuthServiceDep) -> ResetTokenValidationResponse:
return ResetTokenValidationResponse(valid=await service.is_reset_token_valid(token=token))
@router.post(
"/reset-password",
response_model=TokenResponse,
summary="Choisit un nouveau mot de passe depuis un lien reçu par email",
dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_RESET_PASSWORD,
)
async def reset_password(
payload: ResetPasswordRequest,
request: Request,
response: Response,
settings: SettingsDep,
service: AuthServiceDep,
client_ip: str | None = Depends(get_client_ip),
) -> TokenResponse:
response.headers["Cache-Control"] = "no-store"
try:
session = await service.confirm_password_reset(
token=payload.token,
new_password=payload.new_password,
client_ip=client_ip,
user_agent=request.headers.get("user-agent"),
)
except InvalidOrExpiredResetTokenError as erreur:
logger.warning("auth.password_reset.invalid_token ip=%s", client_ip)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=DETAIL_LIEN_RESET
) from erreur
logger.info("auth.password_reset.success user_id=%s", session.principal.id)
return repond(response, settings, session)
+11 -7
View File
@@ -3,16 +3,17 @@ from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from app.api.deps import SessionDep, SettingsDep
from app.api.openapi import REPONSE_INDISPONIBLE
from app.core.logging import get_logger
from app.schemas.health import LivenessStatus, ReadinessStatus
logger = get_logger(__name__)
router = APIRouter(tags=["health"])
router = APIRouter()
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
@router.get("/live", summary="Sonde de vivacite")
@router.get("/live", summary="Sonde de vivacité")
async def liveness(settings: SettingsDep) -> LivenessStatus:
return LivenessStatus(
status="ok",
@@ -22,15 +23,17 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
)
@router.get("/ready", summary="Sonde de disponibilite")
@router.get("/ready", summary="Sonde de disponibilité", responses=REPONSE_INDISPONIBLE)
async def readiness(session: SessionDep) -> ReadinessStatus:
try:
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
except SQLAlchemyError, OSError:
logger.exception("Base de donnees injoignable")
# `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce
# `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`).
except (SQLAlchemyError, OSError): # fmt: skip
logger.exception("Base de données injoignable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Base de donnees injoignable",
detail="Base de données injoignable",
) from None
if version is None:
@@ -40,4 +43,5 @@ async def readiness(session: SessionDep) -> ReadinessStatus:
detail="Extension TimescaleDB absente",
)
return ReadinessStatus(status="ready", database="reachable", timescaledb=version)
logger.debug("Extension TimescaleDB en version %s", version)
return ReadinessStatus(status="ready", database="reachable", timescaledb="loaded")
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from app.api.deps import LecteurDep, PredictionServiceDep
from app.schemas.prediction import PredictionSummaryResponse
router = APIRouter()
@router.get(
"",
response_model=PredictionSummaryResponse,
summary="Dernière prédiction de consommation par site",
)
async def get_predictions(
_: LecteurDep, service: PredictionServiceDep
) -> PredictionSummaryResponse:
resume = await service.summary()
return PredictionSummaryResponse.model_validate(resume)
@@ -0,0 +1,54 @@
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import LecteurDep, ReadingServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.reading import ReadingResponse
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
router = APIRouter()
REPONSES_FENETRE: Reponses = {
**REPONSE_VALIDATION,
400: {
"model": ErrorResponse,
"description": (
"Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre "
"les deux supérieur à 90 jours."
),
},
}
@router.get(
"",
response_model=list[ReadingResponse],
summary="Liste l'historique des lectures",
responses=REPONSES_FENETRE,
)
async def list_readings(
_: LecteurDep,
service: ReadingServiceDep,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(500, ge=1, le=2000),
offset: int = Query(0, ge=0),
) -> list[ReadingResponse]:
try:
lectures = await service.list_history(
site_id=site_id, start=start, end=end, limit=limit, offset=offset
)
except FenetreInverseeError as erreur:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="`start` doit être strictement antérieur à `end`",
) from erreur
except FenetreTropLargeError as erreur:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="L'écart entre `start` et `end` ne peut pas dépasser 90 jours",
) from erreur
return [ReadingResponse.model_validate(lecture) for lecture in lectures]
@@ -0,0 +1,64 @@
from fastapi import APIRouter, HTTPException, status
from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep
from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.recommendation import (
RecommendationGenerationResponse,
RecommendationResponse,
)
from app.services.recommendation import RecommendationNotFoundError
router = APIRouter()
REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION}
REPONSES_INTROUVABLE: Reponses = {
**REPONSE_VALIDATION,
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
}
@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations")
async def list_recommendations(
_: LecteurDep, service: RecommendationServiceDep
) -> list[RecommendationResponse]:
recommendations = await service.list_all()
return [RecommendationResponse.model_validate(r) for r in recommendations]
@router.get(
"/{recommendation_id}",
response_model=RecommendationResponse,
summary="Décrit une recommandation",
responses=REPONSES_INTROUVABLE,
)
async def get_recommendation(
recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep
) -> RecommendationResponse:
try:
recommendation = await service.get_by_id(recommendation_id)
except RecommendationNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
) from erreur
return RecommendationResponse.model_validate(recommendation)
@router.post(
"/generate",
response_model=RecommendationGenerationResponse,
summary="Génère les recommandations à partir des alertes",
responses=REPONSES_GENERATION,
)
async def generate_recommendations(
_: AdminDep,
service: RecommendationServiceDep,
site_id: str | None = None,
) -> RecommendationGenerationResponse:
rapport = await service.generate(site_id=site_id)
return RecommendationGenerationResponse(
alerts_examined=rapport.alertes_examinees,
recommendations_created=rapport.recommandations_creees,
already_present=rapport.deja_presentes,
)
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from app.api.deps import AdminDep, SensorServiceDep
from app.schemas.sensor import SensorStatusResponse
router = APIRouter()
@router.get(
"/status",
response_model=SensorStatusResponse,
summary="État de santé des capteurs par site",
)
async def get_status(_: AdminDep, service: SensorServiceDep) -> SensorStatusResponse:
etat = await service.status()
return SensorStatusResponse.model_validate(etat)
@@ -0,0 +1,52 @@
from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, SiteServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.site import SiteCurrentResponse, SiteResponse
from app.services.site import SiteNotFoundError
router = APIRouter()
REPONSES_INTROUVABLE: Reponses = {
**REPONSE_VALIDATION,
404: {"model": ErrorResponse, "description": "Aucun site ne porte cet identifiant."},
}
@router.get("", response_model=list[SiteResponse], summary="Liste les sites")
async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]:
sites = await service.list_all()
return [SiteResponse.model_validate(site) for site in sites]
@router.get(
"/{site_id}",
response_model=SiteResponse,
summary="Décrit un site",
responses=REPONSES_INTROUVABLE,
)
async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse:
try:
site = await service.get_by_id(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteResponse.model_validate(site)
@router.get(
"/{site_id}/current",
response_model=SiteCurrentResponse,
summary="Dernière mesure d'un site",
responses=REPONSES_INTROUVABLE,
)
async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse:
try:
actuel = await service.current(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteCurrentResponse.model_validate(actuel)
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from app.api.deps import LecteurDep, StatsServiceDep
from app.schemas.stats import StatsSummaryResponse
router = APIRouter()
@router.get(
"/summary",
response_model=StatsSummaryResponse,
summary="Résume la consommation instantanée du parc",
)
async def get_summary(_: LecteurDep, service: StatsServiceDep) -> StatsSummaryResponse:
resume = await service.summary()
return StatsSummaryResponse.model_validate(resume)
+142
View File
@@ -0,0 +1,142 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException, Response, status
from app.api.deps import AdminDep, UserServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.core.logging import get_logger
from app.schemas.errors import ErrorResponse
from app.schemas.user import (
TemporaryPasswordResponse,
UserCreateRequest,
UserResponse,
UserUpdateRequest,
)
from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoundError
router = APIRouter()
logger = get_logger(__name__)
REPONSES_CREATION: Reponses = {
**REPONSE_VALIDATION,
409: {"model": ErrorResponse, "description": "Adresse déjà portée par un autre compte."},
}
REPONSES_INTROUVABLE: Reponses = {
**REPONSE_VALIDATION,
404: {"model": ErrorResponse, "description": "Aucun compte ne porte cet identifiant."},
}
REPONSES_MODIFICATION: Reponses = {
**REPONSES_INTROUVABLE,
400: {"model": ErrorResponse, "description": "Corps vide, aucune modification demandée."},
409: {
"model": ErrorResponse,
"description": (
"L'opération laisserait la plateforme sans administrateur actif, qu'il s'agisse de "
"rétrograder le dernier ou de le désactiver."
),
},
}
@router.get("", response_model=list[UserResponse], summary="Liste les comptes")
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
comptes = await service.list_all()
return [UserResponse.model_validate(compte) for compte in comptes]
@router.post(
"",
response_model=TemporaryPasswordResponse,
status_code=status.HTTP_201_CREATED,
summary="Crée un compte avec un mot de passe provisoire",
responses=REPONSES_CREATION,
)
async def create_user(
payload: UserCreateRequest,
acteur: AdminDep,
service: UserServiceDep,
response: Response,
) -> TemporaryPasswordResponse:
# Le mot de passe provisoire ne doit être conservé par aucun intermédiaire.
response.headers["Cache-Control"] = "no-store"
try:
cree = await service.create(
actor=acteur,
email=payload.email,
role=payload.role,
full_name=payload.full_name,
)
except EmailAlreadyUsedError as erreur:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Adresse déjà utilisée"
) from erreur
logger.info("user.created actor=%s target=%s", acteur.id, cree.user.id)
return TemporaryPasswordResponse(
user=UserResponse.model_validate(cree.user),
temporary_password=cree.temporary_password,
)
@router.patch(
"/{user_id}",
response_model=UserResponse,
summary="Change le rôle ou l'activation",
responses=REPONSES_MODIFICATION,
)
async def update_user(
user_id: UUID,
payload: UserUpdateRequest,
acteur: AdminDep,
service: UserServiceDep,
) -> UserResponse:
compte = None
try:
if payload.role is not None:
compte = await service.change_role(actor=acteur, user_id=user_id, role=payload.role)
if payload.is_active is not None:
compte = await service.set_active(
actor=acteur, user_id=user_id, is_active=payload.is_active
)
except UserNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable"
) from erreur
except LastAdminError as erreur:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Dernier administrateur actif, l'opération le laisserait sans successeur",
) from erreur
if compte is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Aucune modification demandée"
)
logger.info("user.updated actor=%s target=%s", acteur.id, user_id)
return UserResponse.model_validate(compte)
@router.post(
"/{user_id}/password-reset",
response_model=TemporaryPasswordResponse,
summary="Réinitialise le mot de passe et ferme les sessions",
responses=REPONSES_INTROUVABLE,
)
async def reset_password(
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
) -> TemporaryPasswordResponse:
response.headers["Cache-Control"] = "no-store"
try:
reinitialise = await service.reset_password(actor=acteur, user_id=user_id)
except UserNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable"
) from erreur
logger.info("user.password_reset actor=%s target=%s", acteur.id, user_id)
return TemporaryPasswordResponse(
user=UserResponse.model_validate(reinitialise.user),
temporary_password=reinitialise.temporary_password,
)
+37 -3
View File
@@ -1,6 +1,40 @@
from fastapi import APIRouter
from app.api.v1.endpoints import health
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
from app.api.v1.endpoints import (
alerts,
auth,
health,
predictions,
readings,
recommendations,
sensors,
sites,
stats,
users,
)
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health")
api_router = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
api_router.include_router(
alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR
)
api_router.include_router(
recommendations.router,
prefix="/recommendations",
tags=["recommendations"],
responses=REPONSES_LECTEUR,
)
api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
api_router.include_router(
readings.router, prefix="/readings", tags=["readings"], responses=REPONSES_LECTEUR
)
api_router.include_router(
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
)
api_router.include_router(
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
)
+201
View File
@@ -0,0 +1,201 @@
# Pourquoi : `create_admin()` est une commande et non une révision Alembic. Une révision qui
# insérerait un compte graverait son empreinte dans Git pour toujours, et son mot de passe
# serait connu de quiconque lit le dépôt. L'ADR 0001 pose par ailleurs qu'Alembic porte le
# schéma, pas les données.
# Piège : le mot de passe ne transite jamais par `argv`, visible de tout `ps`, ni par
# l'historique du shell. Il est saisi par `getpass` ou tiré au sort par la commande.
import argparse
import asyncio
import json
import secrets
import string
import sys
from getpass import getpass
from pathlib import Path
from typing import Any
from pydantic import SecretStr
from app.core.config import Settings, get_settings
from app.core.hashing import build_hasher
from app.core.roles import Role
from app.db.session import get_session_factory
from app.main import create_app
from app.repositories.alert import AlertRepository
from app.repositories.recommendation import RecommendationRepository
from app.repositories.user import UserRepository
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
from app.services.recommendation import RecommendationService
LONGUEUR_MOT_DE_PASSE_GENERE = 24
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
async def create_admin(
settings: Settings, *, email: str, password: str, force: bool
) -> tuple[bool, str]:
hacheur = build_hasher(
time_cost=settings.argon2_time_cost,
memory_cost_kib=settings.argon2_memory_cost_kib,
parallelism=settings.argon2_parallelism,
max_concurrency=settings.argon2_max_concurrency,
)
empreinte = await hacheur.hash(password)
async with get_session_factory()() as session:
depot = UserRepository(session)
if not force and await depot.count_active_admins() > 0:
return False, "Un administrateur actif existe déjà, relancer avec --force pour forcer"
if await depot.get_by_email(email) is not None:
return False, f"Le compte {email} existe déjà"
await depot.create(
email=email,
password_hash=empreinte,
role=Role.ADMIN,
must_change_password=True,
)
await session.commit()
return (
True,
f"Administrateur {email.strip().lower()} créé, mot de passe à changer à la connexion",
)
async def generate_recommendations(*, site_id: str | None) -> str:
async with get_session_factory()() as session:
service = RecommendationService(
recommendations=RecommendationRepository(session),
alerts=AlertRepository(session),
transaction=session,
)
rapport = await service.generate(site_id=site_id)
return (
f"{rapport.alertes_examinees} alerte(s) examinée(s), "
f"{rapport.recommandations_creees} recommandation(s) créée(s), "
f"{rapport.deja_presentes} déjà présente(s)"
)
# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le
# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
def settings_du_contrat() -> Settings:
return Settings(
_env_file=None,
name="EnerVision API",
version="0.1.0",
env="local",
api_prefix="/api/v1",
secret_key=SecretStr("contrat-openapi-sans-effet-sur-le-schema"),
database_url="postgresql+asyncpg://openapi:contrat@localhost:5432/enervision",
)
def schema_du_contrat() -> dict[str, Any]:
schema: dict[str, Any] = create_app(settings_du_contrat()).openapi()
return schema
def rend_le_contrat() -> str:
return json.dumps(schema_du_contrat(), indent=2, ensure_ascii=False) + "\n"
def export_openapi(destination: Path) -> str:
destination.write_text(rend_le_contrat(), encoding="utf-8")
return f"Contrat OpenAPI écrit dans {destination}"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
sous_commandes = parser.add_subparsers(dest="commande", required=True)
admin = sous_commandes.add_parser("create-admin", help="Crée le premier administrateur")
admin.add_argument("--email", required=True)
admin.add_argument(
"--generate", action="store_true", help="Tire un mot de passe au sort et l'affiche une fois"
)
admin.add_argument(
"--force", action="store_true", help="Crée le compte même si un administrateur existe"
)
contrat = sous_commandes.add_parser(
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
)
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
recommandations = sous_commandes.add_parser(
"generate-recommendations",
help="Applique le moteur de règles aux alertes en base",
)
recommandations.add_argument(
"--site-id", default=None, help="Limite le traitement aux alertes d'un site"
)
return parser
def genere_mot_de_passe() -> str:
tirage = secrets.SystemRandom()
classes = [
string.ascii_uppercase,
string.ascii_lowercase,
string.digits,
SPECIAL_CHARACTERS,
]
reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes)
caracteres = [tirage.choice(classe) for classe in classes]
caracteres += [tirage.choice("".join(classes)) for _ in range(reste)]
tirage.shuffle(caracteres)
return "".join(caracteres)
def read_password(*, generate: bool) -> str:
if generate:
mot_de_passe = genere_mot_de_passe()
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
return mot_de_passe
mot_de_passe = getpass("Mot de passe : ")
if len(mot_de_passe) < PASSWORD_MIN_LENGTH:
raise SystemExit(f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères")
try:
valide_complexite(mot_de_passe)
except ValueError as erreur:
raise SystemExit(str(erreur)) from erreur
if mot_de_passe != getpass("Confirmation : "):
raise SystemExit("Les deux saisies diffèrent")
return mot_de_passe
def main(argv: list[str] | None = None) -> int:
arguments = build_parser().parse_args(argv)
if arguments.commande == "export-openapi":
print(export_openapi(Path(arguments.output)))
return 0
if arguments.commande == "generate-recommendations":
print(asyncio.run(generate_recommendations(site_id=arguments.site_id)))
return 0
mot_de_passe = read_password(generate=arguments.generate)
succes, message = asyncio.run(
create_admin(
get_settings(),
email=arguments.email,
password=mot_de_passe,
force=arguments.force,
)
)
print(message)
return 0 if succes else 1
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+89 -2
View File
@@ -1,10 +1,17 @@
from functools import lru_cache
from typing import Literal
from typing import Literal, Self
from pydantic import Field, SecretStr
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Environment = Literal["local", "dev", "staging", "prod"]
SameSite = Literal["lax", "strict", "none"]
SECRET_KEY_MIN_LENGTH = 32
REFRESH_COOKIE_DEFAUT = "ev_refresh"
SENTINELLES_INTERDITES = frozenset(
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
)
class Settings(BaseSettings):
@@ -27,6 +34,48 @@ class Settings(BaseSettings):
database_pool_size: int = 5
database_max_overflow: int = 10
mock_api_base_url: str = "https://api-mock.charlieandre.fr"
mock_api_username: str | None = None
mock_api_password: SecretStr | None = None
mock_api_timeout_seconds: float = Field(default=10.0, gt=0)
jwt_issuer: str = "enervision-api"
jwt_audience: str = "enervision-web"
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000)
refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT
cookie_path: str = "/api/v1/auth"
cookie_samesite: SameSite = "strict"
cookie_secure: bool | None = None
argon2_time_cost: int = Field(default=2, ge=1, le=10)
argon2_memory_cost_kib: int = Field(default=19456, ge=8192)
argon2_parallelism: int = Field(default=1, ge=1, le=4)
argon2_max_concurrency: int = Field(default=4, ge=1, le=32)
login_window_seconds: int = Field(default=900, ge=60)
login_max_failures_per_identifier_and_ip: int = Field(default=5, ge=1)
login_max_failures_per_ip: int = Field(default=20, ge=1)
login_max_failures_per_identifier: int = Field(default=50, ge=1)
password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600)
password_reset_window_seconds: int = Field(default=900, ge=60)
password_reset_max_requests_per_identifier: int = Field(default=3, ge=1)
password_reset_max_requests_per_ip: int = Field(default=10, ge=1)
smtp_host: str = "localhost"
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_username: str | None = None
smtp_password: SecretStr | None = None
smtp_use_tls: bool = False
smtp_from_address: str = "no-reply@enervision.fr"
frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105
trust_proxy_headers: bool = False
expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None
@property
def allowed_origins(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@@ -35,6 +84,44 @@ class Settings(BaseSettings):
def is_production(self) -> bool:
return self.env == "prod"
@property
def cookies_are_secure(self) -> bool:
return self.env != "local" if self.cookie_secure is None else self.cookie_secure
@property
def api_docs_are_exposed(self) -> bool:
if self.expose_api_docs is not None:
return self.expose_api_docs
return self.env not in ("staging", "prod")
@model_validator(mode="after")
def _refuse_les_configurations_dangereuses(self) -> Self:
secret = self.secret_key.get_secret_value()
if len(secret) < SECRET_KEY_MIN_LENGTH:
raise ValueError(
f"APP_SECRET_KEY doit faire au moins {SECRET_KEY_MIN_LENGTH} caractères"
)
if secret.strip().lower() in SENTINELLES_INTERDITES:
raise ValueError("APP_SECRET_KEY est une valeur d'exemple, il faut en générer une")
# Piège : `create_app()` passe `debug` à FastAPI, qui renvoie alors la trace complète
# au client, et à l'engine, qui journalise le SQL et ses paramètres.
if self.debug and self.env in ("staging", "prod"):
raise ValueError("APP_DEBUG doit rester faux hors des environnements locaux")
if "*" in self.cors_origins:
raise ValueError("APP_CORS_ORIGINS n'accepte pas de joker, les origines sont listées")
# Sans origines, aucun middleware CORS n'est monté et la vérification d'`Origin` des
# routes d'authentification n'a plus de référentiel auquel comparer.
if self.env != "local" and not self.allowed_origins:
raise ValueError("APP_CORS_ORIGINS doit lister au moins une origine hors local")
if self.cookie_samesite == "none" and not self.cookies_are_secure:
raise ValueError("Un cookie SameSite=None est rejeté par les navigateurs sans Secure")
return self
@lru_cache
def get_settings() -> Settings:
+61
View File
@@ -0,0 +1,61 @@
# Piège : le cookie de suppression doit reprendre exactement le nom et le `Path` du cookie
# posé, sinon le navigateur en garde une copie et la déconnexion n'est que cosmétique.
# `RefreshCookie.expired()` existe pour que les deux ne puissent pas diverger.
from dataclasses import asdict, dataclass
from typing import Any, Self
from app.core.config import SameSite, Settings
SECURE_PREFIX = "__Secure-"
@dataclass(frozen=True, slots=True)
class RefreshCookie:
key: str
value: str
max_age: int
path: str
secure: bool
httponly: bool
samesite: SameSite
@classmethod
def build(cls, settings: Settings, value: str) -> Self:
return cls(
key=cookie_name(settings),
value=value,
max_age=settings.refresh_token_ttl_seconds,
path=settings.cookie_path,
secure=settings.cookies_are_secure,
httponly=True,
samesite=settings.cookie_samesite,
)
@classmethod
def expired(cls, settings: Settings) -> Self:
return cls(
key=cookie_name(settings),
value="",
max_age=0,
path=settings.cookie_path,
secure=settings.cookies_are_secure,
httponly=True,
samesite=settings.cookie_samesite,
)
def as_kwargs(self) -> dict[str, Any]:
return asdict(self)
def as_deletion_kwargs(self) -> dict[str, Any]:
# `Response.delete_cookie()` n'accepte ni `value` ni `max_age`, mais il exige le même
# nom, le même chemin et les mêmes attributs, sinon le navigateur garde le cookie.
arguments = asdict(self)
del arguments["value"], arguments["max_age"]
return arguments
def cookie_name(settings: Settings) -> str:
if settings.cookies_are_secure:
return f"{SECURE_PREFIX}{settings.refresh_cookie_name}"
return settings.refresh_cookie_name
+63
View File
@@ -0,0 +1,63 @@
# Piège : `PasswordHasher.verify()` bloque 17 ms. Appelé tel quel dans un `async def`, il fige
# la boucle d'événements et gèle toutes les requêtes en cours, pas seulement la connexion.
# `Argon2Hasher` le pousse donc dans un fil, sous un `CapacityLimiter` : le pool par défaut
# d'anyio accepte 40 fils, soit 40 x 19 Mio dans le pire cas sur une machine qui héberge aussi
# PostgreSQL, Prometheus et Grafana.
# Piège : `verify_dummy()` doit être appelé quand l'utilisateur est introuvable. Sans lui,
# l'écart entre 2 ms et 17 ms est un oracle d'existence de compte, mesurable à distance.
import secrets
import anyio
import anyio.to_thread
from argon2 import PasswordHasher
from argon2.exceptions import Argon2Error, InvalidHashError, VerificationError
_ERREURS_DE_VERIFICATION = (VerificationError, InvalidHashError, Argon2Error)
class Argon2Hasher:
def __init__(self, hasher: PasswordHasher, *, max_concurrency: int) -> None:
self._hasher = hasher
self._limiter = anyio.CapacityLimiter(max_concurrency)
self._leurre = hasher.hash(secrets.token_urlsafe(32))
async def hash(self, password: str) -> str:
return await anyio.to_thread.run_sync(self._hasher.hash, password, limiter=self._limiter)
async def verify(self, stored: str, password: str) -> bool:
return await anyio.to_thread.run_sync(self._verify, stored, password, limiter=self._limiter)
async def verify_dummy(self) -> None:
await self.verify(self._leurre, "")
def needs_rehash(self, stored: str) -> bool:
try:
return self._hasher.check_needs_rehash(stored)
except _ERREURS_DE_VERIFICATION:
return True
def _verify(self, stored: str, password: str) -> bool:
try:
return self._hasher.verify(stored, password)
except _ERREURS_DE_VERIFICATION:
return False
def build_hasher(
*,
time_cost: int,
memory_cost_kib: int,
parallelism: int,
max_concurrency: int,
) -> Argon2Hasher:
return Argon2Hasher(
PasswordHasher(
time_cost=time_cost,
memory_cost=memory_cost_kib,
parallelism=parallelism,
hash_len=32,
salt_len=16,
),
max_concurrency=max_concurrency,
)
+44
View File
@@ -1,8 +1,48 @@
# Pourquoi : `RedactingFilter` est la troisième ligne de défense, pas la première. La première
# est de ne jamais passer un secret au logger, la deuxième de ne jamais mettre un jeton dans
# une URL, que le journal d'accès enregistrerait de toute façon. Le filtre rattrape l'erreur
# que personne n'a relue, notamment l'écho SQL quand `debug` est actif.
import logging
import re
from logging.config import dictConfig
from typing import Final
from app.core.config import Settings
CAVIARDAGE: Final = "[expurgé]"
REMPLACEMENTS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
(re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]{20,}=*"), f"Bearer {CAVIARDAGE}"),
(re.compile(r"eyJ[A-Za-z0-9._-]{20,}"), CAVIARDAGE),
(re.compile(r"\$argon2[a-z0-9]*\$\S+"), CAVIARDAGE),
(
re.compile(r'("?(?:password|mot_de_passe|secret|token)"?\s*[:=]\s*")[^"]*(")'),
rf"\1{CAVIARDAGE}\2",
),
(
re.compile(r"((?:password|mot_de_passe|secret|token)[A-Za-z_]*=)[^&\s;\"]+"),
rf"\1{CAVIARDAGE}",
),
(re.compile(r"(ev_refresh=)[^;\s]+"), rf"\1{CAVIARDAGE}"),
)
def redact(message: str) -> str:
for motif, remplacement in REMPLACEMENTS:
message = motif.sub(remplacement, message)
return message
class RedactingFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
expurge = redact(message)
if expurge != message:
record.msg = expurge
record.args = ()
return True
def configure_logging(settings: Settings) -> None:
formatter = "json" if settings.is_production else "console"
@@ -10,6 +50,9 @@ def configure_logging(settings: Settings) -> None:
{
"version": 1,
"disable_existing_loggers": False,
"filters": {
"redaction": {"()": "app.core.logging.RedactingFilter"},
},
"formatters": {
"console": {
"format": "%(asctime)s %(levelname)-8s %(name)s %(message)s",
@@ -23,6 +66,7 @@ def configure_logging(settings: Settings) -> None:
"default": {
"class": "logging.StreamHandler",
"formatter": formatter,
"filters": ["redaction"],
"stream": "ext://sys.stdout",
},
},
+48
View File
@@ -0,0 +1,48 @@
# Piège : l'URL de réinitialisation porte le jeton en clair. Ne jamais la journaliser :
# `send_password_reset_email()` ne logue que le destinataire, jamais `reset_url`.
from dataclasses import dataclass
from email.message import EmailMessage
import aiosmtplib
from app.core.logging import get_logger
logger = get_logger(__name__)
@dataclass(frozen=True, slots=True)
class SmtpConfig:
host: str
port: int
username: str | None
password: str | None
use_tls: bool
from_address: str
class Mailer:
def __init__(self, config: SmtpConfig) -> None:
self._config = config
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
message = EmailMessage()
message["From"] = self._config.from_address
message["To"] = to
message["Subject"] = "Réinitialisation de votre mot de passe EnerVision"
message.set_content(
"Une réinitialisation de mot de passe a été demandée pour ce compte.\n\n"
f"Ouvrez ce lien dans les 15 minutes pour choisir un nouveau mot de passe : "
f"{reset_url}\n\n"
"Si vous n'êtes pas à l'origine de cette demande, ignorez cet email."
)
_, message_recu = await aiosmtplib.send(
message,
hostname=self._config.host,
port=self._config.port,
username=self._config.username,
password=self._config.password,
use_tls=self._config.use_tls,
)
logger.info("mailer.password_reset_sent to=%s smtp_response=%s", to, message_recu)
+18
View File
@@ -0,0 +1,18 @@
# Pourquoi : tout le code métier dépend de `Principal` et jamais du modèle ORM ni des claims
# du jeton. C'est ce qui garde la bascule vers un fournisseur OIDC locale à
# `get_current_principal()` et à `AuthService.authenticate()`, au lieu de la répandre dans
# chaque endpoint.
from dataclasses import dataclass
from uuid import UUID
from app.core.roles import AccountKind, Role
@dataclass(frozen=True, slots=True)
class Principal:
id: UUID
email: str
role: Role
kind: AccountKind
must_change_password: bool
+26
View File
@@ -0,0 +1,26 @@
from enum import StrEnum
from typing import Final
class Role(StrEnum):
# Contrainte : ces valeurs voyagent en base, en JSON et dans les jetons. Elles restent
# en ASCII, contrairement au libellé « opérateur » affiché à l'utilisateur.
LECTEUR = "lecteur"
OPERATEUR = "operateur"
ADMIN = "admin"
class AccountKind(StrEnum):
HUMAIN = "human"
SERVICE = "service"
ROLE_RANK: Final[dict[Role, int]] = {
Role.LECTEUR: 0,
Role.OPERATEUR: 1,
Role.ADMIN: 2,
}
def has_at_least(actual: Role, required: Role) -> bool:
return ROLE_RANK[actual] >= ROLE_RANK[required]
+117
View File
@@ -0,0 +1,117 @@
# Piège : `decode_access_token()` porte trois barrières indépendantes, et retirer l'une
# d'elles ne casse aucun test évident. L'algorithme est épinglé, sinon un jeton forgé en
# `alg: none` passerait. L'audience et l'émetteur sont vérifiés, sinon un jeton émis pour
# un autre service serait accepté. Le claim `typ` est comparé, sinon un jeton de
# rafraîchissement servirait de jeton d'accès, ce qui transformerait une fenêtre de
# 15 minutes en fenêtre de 7 jours.
# Contrainte : ce module ne lit jamais `get_settings()`, qui est mis en cache par
# `lru_cache` et se contaminerait entre tests. Tout paramètre arrive par `TokenPolicy`.
import hashlib
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Final
from uuid import UUID, uuid4
import jwt
ACCESS_TOKEN_TYPE: Final = "access" # noqa: S105
REFRESH_SECRET_BYTES: Final = 32
_ALGORITHME: Final = "HS256"
_CLAIMS_REQUIS: Final = ["iss", "aud", "sub", "iat", "exp", "jti", "typ", "role", "kind"]
class TokenInvalidError(Exception):
pass
class TokenExpiredError(TokenInvalidError):
pass
@dataclass(frozen=True, slots=True)
class TokenPolicy:
secret: str
issuer: str
audience: str
access_ttl: timedelta
@dataclass(frozen=True, slots=True)
class AccessClaims:
subject: UUID
role: str
kind: str
token_id: UUID
issued_at: datetime
def encode_access_token(
policy: TokenPolicy,
*,
subject: UUID,
role: str,
kind: str,
now: datetime | None = None,
) -> str:
emis_a = now or datetime.now(UTC)
return jwt.encode(
{
"iss": policy.issuer,
"aud": policy.audience,
"sub": str(subject),
"iat": emis_a,
"exp": emis_a + policy.access_ttl,
"jti": str(uuid4()),
"typ": ACCESS_TOKEN_TYPE,
"role": role,
"kind": kind,
},
policy.secret,
algorithm=_ALGORITHME,
)
def decode_access_token(policy: TokenPolicy, token: str) -> AccessClaims:
try:
charge = jwt.decode(
token,
policy.secret,
algorithms=[_ALGORITHME],
audience=policy.audience,
issuer=policy.issuer,
options={"require": _CLAIMS_REQUIS},
)
except jwt.ExpiredSignatureError as erreur:
raise TokenExpiredError("Jeton expiré") from erreur
except jwt.InvalidTokenError as erreur:
raise TokenInvalidError("Jeton invalide") from erreur
if charge["typ"] != ACCESS_TOKEN_TYPE:
raise TokenInvalidError("Type de jeton inattendu")
try:
sujet = UUID(charge["sub"])
identifiant = UUID(charge["jti"])
except (AttributeError, TypeError, ValueError) as erreur:
raise TokenInvalidError("Identifiants du jeton illisibles") from erreur
return AccessClaims(
subject=sujet,
role=str(charge["role"]),
kind=str(charge["kind"]),
token_id=identifiant,
issued_at=datetime.fromtimestamp(charge["iat"], tz=UTC),
)
def generate_refresh_secret() -> str:
return secrets.token_urlsafe(REFRESH_SECRET_BYTES)
# SHA-256 nu, pas Argon2id : 256 bits de CSPRNG n'ont ni dictionnaire ni préimage atteignable,
# et une KDF lente coûterait 17 ms à chaque rafraîchissement pour aucun gain.
def fingerprint_refresh(secret: str) -> bytes:
return hashlib.sha256(secret.encode("utf-8")).digest()
+1 -1
View File
@@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Base declarative commune a tous les modeles."""
"""Base déclarative commune à tous les modèles."""
@@ -0,0 +1,68 @@
# Détection d'alertes internes EnerVision (issue #104) : script lancé à la main pour l'instant,
# comme `enervision_ml.score` côté ML, sans automatisation Airflow pour l'ordonnancer.
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime
from app.core.config import get_settings
from app.db.session import get_session_factory
from app.repositories.alert import AlertRepository
from app.repositories.prediction import PredictionRepository
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.alert import AlertService
async def run_detection(*, now: datetime | None = None, site_id: str | None = None) -> int:
"""Exécute les cinq règles de détection et enregistre les nouvelles alertes. Rend le nombre de
lignes effectivement insérées (les doublons de `source_alert_id` sont silencieusement
ignorés)."""
async with get_session_factory()() as session:
service = AlertService(
alerts=AlertRepository(session),
readings=ReadingRepository(session),
predictions=PredictionRepository(session),
sites=SiteRepository(session),
)
nouvelles = await service.detect(now=now, site_id=site_id)
await session.commit()
return len(nouvelles)
def _parse_instant(valeur: str) -> datetime:
instant = datetime.fromisoformat(valeur)
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="python -m app.detection.internal_alerts",
description="Détection d'alertes internes EnerVision",
)
parser.add_argument("--site-id", default=None, help="Limite la détection à un seul site.")
parser.add_argument(
"--now",
type=_parse_instant,
default=None,
help=(
"Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante."
),
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
# Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base.
get_settings()
nombre = asyncio.run(run_detection(now=args.now, site_id=args.site_id))
print(f"{nombre} nouvelle(s) alerte(s) enregistrée(s).")
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
View File
+621
View File
@@ -0,0 +1,621 @@
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
from pathlib import Path
from typing import Any, cast
import pandas as pd
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
REQUIRED_COLUMNS = {
"timestamp",
"site_id",
"site_type",
"site_name",
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"hour",
"day_of_week",
"day_name",
"month",
"is_weekend",
"is_working_hours",
}
MEASURE_COLUMNS = [
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
]
SOURCE_NAME = "csv"
def compute_sha256(path: Path) -> str:
"""Calcule l'empreinte SHA-256 du fichier source."""
sha256 = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
sha256.update(block)
return sha256.hexdigest()
def load_metadata(path: Path) -> dict[str, Any]:
"""Charge les métadonnées fournies avec le dataset."""
with path.open("r", encoding="utf-8") as source:
metadata = json.load(source)
if not isinstance(metadata, dict):
raise ValueError("Le fichier de métadonnées doit contenir un objet JSON.")
return cast(dict[str, Any], metadata)
def classify_quality(
row: dict[str, Any],
) -> tuple[str, list[str]]:
"""
Déduit une qualité technique à partir des champs manquants.
Les valeurs NULL sont conservées. On ne cherche pas ici à
déterminer la cause physique exacte de leur absence.
"""
missing = [column for column in MEASURE_COLUMNS if pd.isna(row.get(column))]
if not missing:
quality = "good"
elif len(missing) == len(MEASURE_COLUMNS):
quality = "critical"
elif "consumption_kwh" in missing:
quality = "degraded"
else:
quality = "partial"
reasons = [f"missing:{column}" for column in missing]
return quality, reasons
def validate_source(
frame: pd.DataFrame,
metadata: dict[str, Any],
) -> None:
"""Valide le dataset avant tout chargement en base."""
missing_columns = REQUIRED_COLUMNS.difference(frame.columns)
if missing_columns:
raise ValueError(f"Colonnes obligatoires absentes : {sorted(missing_columns)}")
expected_records = int(metadata["total_records"])
if len(frame) != expected_records:
raise ValueError(f"Nombre de lignes inattendu : {len(frame)} au lieu de {expected_records}")
expected_sites = set(metadata["sites"].keys())
actual_sites = set(frame["site_id"].unique())
if actual_sites != expected_sites:
raise ValueError(
f"Sites incohérents. Attendus={sorted(expected_sites)}, trouvés={sorted(actual_sites)}"
)
duplicated = frame.duplicated(subset=["site_id", "timestamp"]).sum()
if duplicated:
raise ValueError(f"{duplicated} doublons (site_id, timestamp) détectés")
static_variants = frame.groupby("site_id")[["site_type", "site_name"]].nunique()
if (static_variants > 1).any().any():
raise ValueError("Un site possède plusieurs valeurs de site_type ou site_name.")
# Vérifie également que tous les timestamps
# peuvent être interprétés correctement.
pd.to_datetime(
frame["timestamp"],
errors="raise",
)
def normalize_timestamps(
frame: pd.DataFrame,
source_timezone: str,
) -> pd.DataFrame:
"""
Normalise les timestamps et leur associe une timezone.
Les timestamps originaux sont conservés dans une colonne
temporaire afin de pouvoir les stocker dans raw_data.
"""
normalized = frame.copy()
normalized["_source_timestamp"] = normalized["timestamp"]
timestamps = pd.to_datetime(
normalized["timestamp"],
errors="raise",
)
if timestamps.dt.tz is None:
timestamps = timestamps.dt.tz_localize(source_timezone)
else:
timestamps = timestamps.dt.tz_convert(source_timezone)
normalized["timestamp"] = timestamps
return normalized
def to_json_value(value: Any) -> Any:
"""
Convertit une valeur Pandas/Numpy en valeur
compatible JSON.
"""
if value is None:
return None
try:
if pd.isna(value):
return None
except TypeError, ValueError:
pass
if isinstance(value, pd.Timestamp):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value
async def ensure_dataset(
connection: AsyncConnection,
metadata: dict[str, Any],
sha256: str,
source_timezone: str,
storage_uri: str,
) -> int:
"""
Crée l'entrée dataset si elle n'existe pas.
Le SHA-256 permet de reconnaître un fichier déjà importé
et participe à l'idempotence et à la traçabilité.
"""
result = await connection.execute(
text(
"""
SELECT dataset_id
FROM dataset
WHERE archive_sha256 = :sha256
LIMIT 1
"""
),
{
"sha256": sha256,
},
)
existing = result.scalar_one_or_none()
if existing is not None:
return int(existing)
metadata_summary = {
"generator_version": metadata.get("generator_version"),
"total_sites": metadata.get("total_sites"),
"total_records": metadata.get("total_records"),
"date_range": metadata.get("date_range"),
"frequency": metadata.get("frequency"),
"null_injection_enabled": metadata.get("null_injection_enabled"),
"null_strategies": metadata.get("null_strategies"),
"importer": "historical_import_v1",
}
result = await connection.execute(
text(
"""
INSERT INTO dataset (
dataset_name,
archive_sha256,
storage_uri,
source_timezone,
"metadata"
)
VALUES (
:dataset_name,
:archive_sha256,
:storage_uri,
:source_timezone,
CAST(:metadata AS jsonb)
)
RETURNING dataset_id
"""
),
{
"dataset_name": ("EnerVision historical dataset 2023-2024"),
"archive_sha256": sha256,
"storage_uri": storage_uri,
"source_timezone": source_timezone,
"metadata": json.dumps(
metadata_summary,
ensure_ascii=False,
),
},
)
return int(result.scalar_one())
async def upsert_sites(
connection: AsyncConnection,
frame: pd.DataFrame,
) -> None:
"""Insère ou met à jour les sites du dataset."""
sites = cast(
list[dict[str, Any]],
frame[
[
"site_id",
"site_type",
"site_name",
]
]
.drop_duplicates(subset=["site_id"])
.to_dict(orient="records"),
)
await connection.execute(
text(
"""
INSERT INTO site (
site_id,
site_type,
site_name
)
VALUES (
:site_id,
:site_type,
:site_name
)
ON CONFLICT (site_id)
DO UPDATE SET
site_type = EXCLUDED.site_type,
site_name = EXCLUDED.site_name
"""
),
sites,
)
def build_reading_batch(
chunk: pd.DataFrame,
dataset_id: int,
) -> list[dict[str, Any]]:
"""
Transforme un chunk Pandas en lignes prêtes
à être chargées dans la table reading.
"""
rows: list[dict[str, Any]] = []
records = cast(
list[dict[str, Any]],
chunk.to_dict(orient="records"),
)
for record in records:
quality, reasons = classify_quality(record)
raw_data = {
column: to_json_value(value)
for column, value in record.items()
if column != "_source_timestamp"
}
# Dans raw_data, on conserve le timestamp
# exactement tel qu'il était dans le CSV.
raw_data["timestamp"] = to_json_value(record["_source_timestamp"])
rows.append(
{
"site_id": record["site_id"],
"timestamp": record["timestamp"],
"source": SOURCE_NAME,
"dataset_id": dataset_id,
# Non fourni par le dataset historique.
"consumption_kw": None,
"consumption_kwh": to_json_value(record["consumption_kwh"]),
"consumption_euros": to_json_value(record["consumption_euros"]),
# Non fournis par le CSV historique.
"voltage_v": None,
"current_a": None,
"power_factor": None,
"temperature_celsius": (to_json_value(record["temperature_celsius"])),
"humidity_percent": (to_json_value(record["humidity_percent"])),
"solar_irradiance_wm2": (to_json_value(record["solar_irradiance_wm2"])),
"is_working_hours": bool(record["is_working_hours"]),
"data_quality": quality,
"null_reasons": reasons,
# Aucune imputation pendant l'ingestion RAW.
# Les valeurs manquantes sont conservées telles quelles
# afin de préserver la donnée source.
"imputed_values": None,
"imputation_method": None,
# Conservation de la donnée source
# pour la traçabilité.
"raw_data": json.dumps(
raw_data,
ensure_ascii=False,
),
}
)
return rows
READING_INSERT = text(
"""
INSERT INTO reading (
site_id,
timestamp,
source,
dataset_id,
consumption_kw,
consumption_kwh,
consumption_euros,
voltage_v,
current_a,
power_factor,
temperature_celsius,
humidity_percent,
solar_irradiance_wm2,
is_working_hours,
data_quality,
null_reasons,
imputed_values,
imputation_method,
raw_data
)
VALUES (
:site_id,
:timestamp,
:source,
:dataset_id,
:consumption_kw,
:consumption_kwh,
:consumption_euros,
:voltage_v,
:current_a,
:power_factor,
:temperature_celsius,
:humidity_percent,
:solar_irradiance_wm2,
:is_working_hours,
:data_quality,
:null_reasons,
CAST(:imputed_values AS jsonb),
:imputation_method,
CAST(:raw_data AS jsonb)
)
ON CONFLICT DO NOTHING
"""
)
async def import_historical(
csv_path: Path,
metadata_path: Path,
source_timezone: str,
batch_size: int,
dry_run: bool,
storage_uri: str,
) -> None:
"""
Exécute le pipeline ETL historique EnerVision.
Étapes :
1. Extract
2. Validate
3. Transform
4. Load
"""
metadata = load_metadata(metadata_path)
frame = pd.read_csv(csv_path)
validate_source(
frame,
metadata,
)
print(f"Lignes : {len(frame)}")
print(f"Sites : {frame['site_id'].nunique()}")
print(f"Période : {frame['timestamp'].min()} -> {frame['timestamp'].max()}")
print(f"Doublons : {frame.duplicated(['site_id', 'timestamp']).sum()}")
print("\nValeurs NULL :")
print(frame[MEASURE_COLUMNS].isna().sum())
sha256 = compute_sha256(csv_path)
print(f"\nSHA-256 : {sha256}")
if dry_run:
print("\nDry-run terminé : aucune donnée écrite.")
return
normalized = normalize_timestamps(
frame,
source_timezone,
)
settings = get_settings()
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection:
dataset_id = await ensure_dataset(
connection=connection,
metadata=metadata,
sha256=sha256,
source_timezone=source_timezone,
storage_uri=storage_uri,
)
await upsert_sites(
connection,
normalized,
)
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
before = int(result.scalar_one())
for start in range(
0,
len(normalized),
batch_size,
):
chunk = normalized.iloc[start : start + batch_size]
rows = build_reading_batch(
chunk,
dataset_id,
)
await connection.execute(
READING_INSERT,
rows,
)
loaded = min(
start + batch_size,
len(normalized),
)
print(f"Chargement : {loaded}/{len(normalized)}")
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
after = int(result.scalar_one())
print("\nImport terminé.")
print(f"dataset_id : {dataset_id}")
print(f"lectures avant : {before}")
print(f"lectures après : {after}")
print(f"nouvelles lectures : {after - before}")
finally:
await engine.dispose()
def parse_args() -> argparse.Namespace:
"""Définit les arguments CLI de l'import."""
parser = argparse.ArgumentParser(description=("Import historique EnerVision"))
parser.add_argument(
"--csv",
type=Path,
required=True,
help="Chemin vers le CSV historique.",
)
parser.add_argument(
"--metadata",
type=Path,
required=True,
help=("Chemin vers le fichier dataset_metadata.json."),
)
parser.add_argument(
"--source-timezone",
default="UTC",
help=("Timezone associée aux timestamps du dataset. Défaut : UTC."),
)
parser.add_argument(
"--batch-size",
type=int,
default=1000,
help=("Nombre de lignes insérées par batch. Défaut : 1000."),
)
parser.add_argument(
"--dry-run",
action="store_true",
help=("Valide les données sans écrire en base."),
)
return parser.parse_args()
def main() -> None:
"""Point d'entrée CLI du pipeline."""
args = parse_args()
if args.batch_size <= 0:
raise ValueError("--batch-size doit être strictement supérieur à 0.")
# resolve() est volontairement exécuté ici,
# dans la partie synchrone du programme.
# Cela évite une opération filesystem bloquante
# à l'intérieur d'une fonction async.
storage_uri = args.csv.resolve().as_uri()
asyncio.run(
import_historical(
csv_path=args.csv,
metadata_path=args.metadata,
source_timezone=(args.source_timezone),
batch_size=args.batch_size,
dry_run=args.dry_run,
storage_uri=storage_uri,
)
)
if __name__ == "__main__":
main()
+416
View File
@@ -0,0 +1,416 @@
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille
# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL
# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par
# construction, et raw_data conserve de toute façon la réponse d'origine intacte.
from __future__ import annotations
import argparse
import asyncio
import json
from datetime import datetime
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
SOURCE_HISTORY = "api_history"
MAX_SITES = 100
MAX_LIMIT = 1000
# Les quatre seules valeurs que la contrainte ck_reading_quality accepte.
ACCEPTED_QUALITIES = frozenset({"good", "partial", "degraded", "critical"})
PHYSICAL_BOUNDS: dict[str, tuple[float, float]] = {
"consumption_kw": (0.0, 100_000.0),
"consumption_kwh": (0.0, 100_000.0),
"voltage_v": (0.0, 1_000.0),
"current_a": (0.0, 10_000.0),
"power_factor": (0.0, 1.0),
"temperature_celsius": (-90.0, 60.0),
"humidity_percent": (0.0, 100.0),
}
CAPACITY_BOUNDS = (0.0, 100_000.0)
def create_mock_api_client() -> httpx.AsyncClient:
settings = get_settings()
if settings.mock_api_username is None or settings.mock_api_password is None:
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
return httpx.AsyncClient(
base_url=settings.mock_api_base_url.rstrip("/"),
auth=(
settings.mock_api_username,
settings.mock_api_password.get_secret_value(),
),
timeout=settings.mock_api_timeout_seconds,
)
def read_text(payload: dict[str, Any], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value:
raise ValueError(f"Champ {key} absent ou invalide dans la réponse de l'API Mock.")
return value
def optional_text(value: Any) -> str | None:
return value if isinstance(value, str) else None
def coerce_measure(
value: Any,
bounds: tuple[float, float],
) -> float | None:
if isinstance(value, bool) or not isinstance(value, int | float):
return None
lower, upper = bounds
# Écarte aussi NaN et les infinis, qu'aucune comparaison de bornes ne retient.
return float(value) if lower <= value <= upper else None
def resolve_quality(
value: Any,
rejected: list[str],
) -> str | None:
quality = value if isinstance(value, str) and value in ACCEPTED_QUALITIES else None
if rejected:
return "critical" if quality == "critical" else "degraded"
return quality
def resolve_null_reasons(
value: Any,
rejected: list[str],
) -> list[str]:
reported = [str(reason) for reason in value] if isinstance(value, list) else []
return reported + rejected
async def fetch_sites(
client: httpx.AsyncClient,
) -> list[dict[str, Any]]:
response = await client.get("/api/v1/sites")
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("La réponse /api/v1/sites doit être une liste.")
if len(payload) > MAX_SITES:
raise ValueError(f"La réponse /api/v1/sites dépasse le plafond de {MAX_SITES} sites.")
return payload
def build_site_row(
site: dict[str, Any],
) -> dict[str, Any]:
return {
"site_id": read_text(site, "site_id"),
"site_type": read_text(site, "site_type"),
"site_name": read_text(site, "site_name"),
"location": optional_text(site.get("location")),
"capacity_kw": coerce_measure(site.get("capacity_kw"), CAPACITY_BOUNDS),
"status": optional_text(site.get("status")),
}
async def upsert_sites(
connection: AsyncConnection,
sites: list[dict[str, Any]],
) -> None:
rows = [build_site_row(site) for site in sites]
if not rows:
return
await connection.execute(
text(
"""
INSERT INTO site (
site_id,
site_type,
site_name,
location,
capacity_kw,
status
)
VALUES (
:site_id,
:site_type,
:site_name,
:location,
:capacity_kw,
:status
)
ON CONFLICT (site_id)
DO UPDATE SET
site_type = EXCLUDED.site_type,
site_name = EXCLUDED.site_name,
location = EXCLUDED.location,
capacity_kw = EXCLUDED.capacity_kw,
status = EXCLUDED.status
"""
),
rows,
)
async def fetch_readings(
client: httpx.AsyncClient,
site_id: str,
start_time: datetime,
end_time: datetime,
limit: int = MAX_LIMIT,
) -> list[dict[str, Any]]:
response = await client.get(
"/api/v1/readings",
params={
"site_id": site_id,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"limit": limit,
},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("La réponse /api/v1/readings doit être une liste.")
if len(payload) > limit:
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
return payload
def build_reading_row(
reading: dict[str, Any],
) -> dict[str, Any]:
measures: dict[str, float | None] = {}
rejected: list[str] = []
for name, bounds in PHYSICAL_BOUNDS.items():
received = reading.get(name)
measures[name] = coerce_measure(received, bounds)
if received is not None and measures[name] is None:
rejected.append(f"out_of_physical_bounds:{name}")
return {
"site_id": read_text(reading, "site_id"),
"timestamp": parse_datetime(read_text(reading, "timestamp")),
"source": SOURCE_HISTORY,
"dataset_id": None,
**measures,
"consumption_euros": None,
"solar_irradiance_wm2": None,
"is_working_hours": None,
"data_quality": resolve_quality(reading.get("data_quality"), rejected),
"null_reasons": resolve_null_reasons(reading.get("null_reasons"), rejected),
"imputed_values": None,
"imputation_method": None,
"raw_data": json.dumps(
reading,
ensure_ascii=False,
),
}
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
# nommée, DO NOTHING avalerait aussi une violation de clé primaire.
READING_INSERT = text(
"""
INSERT INTO reading (
site_id,
timestamp,
source,
dataset_id,
consumption_kw,
consumption_kwh,
consumption_euros,
voltage_v,
current_a,
power_factor,
temperature_celsius,
humidity_percent,
solar_irradiance_wm2,
is_working_hours,
data_quality,
null_reasons,
imputed_values,
imputation_method,
raw_data
)
VALUES (
:site_id,
:timestamp,
:source,
:dataset_id,
:consumption_kw,
:consumption_kwh,
:consumption_euros,
:voltage_v,
:current_a,
:power_factor,
:temperature_celsius,
:humidity_percent,
:solar_irradiance_wm2,
:is_working_hours,
:data_quality,
:null_reasons,
CAST(:imputed_values AS jsonb),
:imputation_method,
CAST(:raw_data AS jsonb)
)
ON CONFLICT (site_id, timestamp, source, (coalesce(dataset_id, 0)))
DO NOTHING
"""
)
def build_reading_batch(
readings: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return [build_reading_row(reading) for reading in readings]
async def import_mock_api_history(
start_time: datetime,
end_time: datetime,
limit: int,
dry_run: bool,
) -> None:
settings = get_settings()
async with create_mock_api_client() as client:
sites = await fetch_sites(client)
print(f"Sites récupérés : {len(sites)}")
all_readings: list[dict[str, Any]] = []
for site in sites:
site_id = read_text(site, "site_id")
readings = await fetch_readings(
client=client,
site_id=site_id,
start_time=start_time,
end_time=end_time,
limit=limit,
)
print(f"{site_id}: {len(readings)} lectures")
all_readings.extend(readings)
print(f"Lectures récupérées : {len(all_readings)}")
if dry_run:
print("Dry-run terminé : aucune donnée écrite.")
return
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection:
await upsert_sites(
connection,
sites,
)
rows = build_reading_batch(all_readings)
if rows:
await connection.execute(
READING_INSERT,
rows,
)
finally:
await engine.dispose()
print("Import API Mock terminé.")
def parse_datetime(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision"))
parser.add_argument(
"--start-time",
required=True,
type=parse_datetime,
)
parser.add_argument(
"--end-time",
required=True,
type=parse_datetime,
)
parser.add_argument(
"--limit",
type=int,
default=MAX_LIMIT,
)
parser.add_argument(
"--dry-run",
action="store_true",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.limit < 1 or args.limit > MAX_LIMIT:
raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
if args.start_time >= args.end_time:
raise ValueError("--start-time doit être antérieur à --end-time.")
asyncio.run(
import_mock_api_history(
start_time=args.start_time,
end_time=args.end_time,
limit=args.limit,
dry_run=args.dry_run,
)
)
if __name__ == "__main__":
main()
+73 -8
View File
@@ -1,10 +1,19 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator
from starlette.requests import Request
from starlette.responses import HTMLResponse
from app.api.errors import register_error_handlers
from app.api.middleware import SecurityHeadersMiddleware
from app.api.openapi import DESCRIPTION, SUMMARY, TAGS
from app.api.security import require_metrics_token
from app.api.v1.router import api_router
from app.core.config import Settings, get_settings
from app.core.logging import configure_logging, get_logger
@@ -12,12 +21,17 @@ from app.db.session import get_engine
logger = get_logger(__name__)
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
STATIC_DIR = Path(__file__).parent / "static"
LOGO_URL = "/static/logo-icon.png"
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
logger.info(
"Demarrage de %s %s en environnement %s", settings.name, settings.version, settings.env
"Démarrage de %s %s en environnement %s", settings.name, settings.version, settings.env
)
yield
await get_engine().dispose()
@@ -27,28 +41,79 @@ def create_app(settings: Settings | None = None) -> FastAPI:
resolved = settings or get_settings()
configure_logging(resolved)
documentee = resolved.api_docs_are_exposed
application = FastAPI(
title=resolved.name,
version=resolved.version,
summary=SUMMARY,
description=DESCRIPTION,
openapi_tags=TAGS,
debug=resolved.debug,
lifespan=lifespan,
docs_url=None if resolved.is_production else "/docs",
redoc_url=None if resolved.is_production else "/redoc",
openapi_url=None if resolved.is_production else "/openapi.json",
docs_url=None,
redoc_url=None,
openapi_url="/openapi.json" if documentee else None,
)
if documentee:
application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo
# en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon.
openapi_original = application.openapi
def openapi_avec_logo() -> dict[str, object]:
schema = openapi_original()
schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"}
return schema
application.openapi = openapi_avec_logo # type: ignore[method-assign]
@application.get("/docs", include_in_schema=False)
async def docs_swagger(_: Request) -> HTMLResponse:
return get_swagger_ui_html(
openapi_url="/openapi.json",
title=f"{application.title} · Swagger UI",
swagger_favicon_url=LOGO_URL,
)
@application.get("/redoc", include_in_schema=False)
async def docs_redoc(_: Request) -> HTMLResponse:
return get_redoc_html(
openapi_url="/openapi.json",
title=f"{application.title} · ReDoc",
redoc_favicon_url=LOGO_URL,
)
application.add_middleware(SecurityHeadersMiddleware)
if resolved.allowed_origins:
# Méthodes et en-têtes listés plutôt que joker : avec `allow_credentials`, la liste
# d'origines devient l'unique contrôle, autant documenter le contrat exact.
application.add_middleware(
CORSMiddleware,
allow_origins=resolved.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=METHODES_AUTORISEES,
allow_headers=EN_TETES_AUTORISES,
expose_headers=["Retry-After"],
max_age=600,
)
register_error_handlers(application)
Instrumentator().instrument(application).expose(
application, endpoint="/metrics", include_in_schema=False
application,
endpoint="/metrics",
include_in_schema=False,
dependencies=[Depends(require_metrics_token)],
)
application.include_router(api_router, prefix=resolved.api_prefix)
# Piège : sans cette surcharge, une configuration passée à `create_app()` ne piloterait
# que la construction, et les dépendances continueraient de lire `get_settings()` depuis
# l'environnement. Un test « en production » ne testerait alors pas la production.
if settings is not None:
application.dependency_overrides[get_settings] = lambda: resolved
return application
+25 -2
View File
@@ -1,2 +1,25 @@
# Piege : tout modele absent de ce module reste invisible de `alembic revision
# --autogenerate`, qui genererait alors un drop de sa table.
# Piège : tout modèle absent de ce module reste invisible de `alembic revision
# --autogenerate`, qui générerait alors un drop de sa table.
from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
from app.models.login_attempt import LoginAttempt
from app.models.password_reset_attempt import PasswordResetAttempt
from app.models.password_reset_token import PasswordResetToken
from app.models.refresh_token import RefreshToken
from app.models.user import AppUser
__all__ = [
"Alert",
"AppUser",
"AuditLog",
"Dataset",
"LoginAttempt",
"PasswordResetAttempt",
"PasswordResetToken",
"Prediction",
"Reading",
"Recommendation",
"RefreshToken",
"Site",
]
+66
View File
@@ -0,0 +1,66 @@
# Pourquoi : `actor_id` ne porte volontairement aucune clé étrangère. Une contrainte
# `ON DELETE SET NULL` déclencherait un UPDATE que le déclencheur d'ajout seul refuserait, donc
# la suppression d'un compte échouerait ; une contrainte `NO ACTION` interdirait toute
# suppression. `actor_email` et `actor_role` sont dénormalisés pour la même raison : le journal
# dit ce qui était vrai au moment de l'acte, pas ce qui est vrai aujourd'hui.
import uuid
from datetime import datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, Text, func
from sqlalchemy.dialects.postgresql import INET, JSONB
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AuditOutcome(StrEnum):
SUCCES = "success"
ECHEC = "failure"
class AuditAction(StrEnum):
COMPTE_CREE = "user.created"
COMPTE_ROLE_CHANGE = "user.role_changed"
COMPTE_DESACTIVE = "user.disabled"
COMPTE_ACTIVE = "user.enabled"
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed"
MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested"
MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service"
REFRESH_REUTILISE = "auth.refresh_reuse_detected"
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
ADMIN_AMORCE = "bootstrap.admin_created"
ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in AuditOutcome)
class AuditLog(Base):
__tablename__ = "audit_log"
__table_args__ = (
CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_audit_log_outcome"),
Index("ix_audit_log_date", "occurred_at"),
Index("ix_audit_log_action_date", "action", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
actor_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
actor_email: Mapped[str | None] = mapped_column(Text, nullable=True)
actor_role: Mapped[str | None] = mapped_column(Text, nullable=True)
action: Mapped[str] = mapped_column(Text, nullable=False)
target_type: Mapped[str | None] = mapped_column(Text, nullable=True)
target_id: Mapped[str | None] = mapped_column(Text, nullable=True)
outcome: Mapped[str] = mapped_column(Text, nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
detail: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, server_default=func.jsonb_build_object()
)
+210
View File
@@ -0,0 +1,210 @@
"""Tables du modèle de données EnerVision (CSV, API Mock et résultats ML)."""
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
Double,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Dataset(Base):
__tablename__ = "dataset"
__table_args__ = (
CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"),
UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"),
)
dataset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
dataset_name: Mapped[str] = mapped_column(Text)
archive_sha256: Mapped[str] = mapped_column(String(64))
storage_uri: Mapped[str] = mapped_column(Text)
source_timezone: Mapped[str | None] = mapped_column(Text)
# "metadata" est réservé par SQLAlchemy ; le nom SQL reste inchangé.
dataset_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB(none_as_null=True))
class Site(Base):
__tablename__ = "site"
site_id: Mapped[str] = mapped_column(Text, primary_key=True)
site_name: Mapped[str] = mapped_column(Text)
site_type: Mapped[str] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(Text)
capacity_kw: Mapped[float | None] = mapped_column(Double)
status: Mapped[str | None] = mapped_column(Text)
class Reading(Base):
__tablename__ = "reading"
__table_args__ = (
CheckConstraint(
"source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source"
),
CheckConstraint(
"(source = 'csv' AND dataset_id IS NOT NULL) OR "
"(source IN ('api_current', 'api_history') AND dataset_id IS NULL)",
name="ck_reading_dataset_source",
),
CheckConstraint(
"data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')",
name="ck_reading_quality",
),
CheckConstraint(
"(imputed_values IS NULL AND imputation_method IS NULL) OR "
"(imputed_values IS NOT NULL AND imputation_method IS NOT NULL)",
name="ck_reading_imputation",
),
Index("ix_reading_site_timestamp", "site_id", "timestamp"),
Index("ix_reading_dataset_id", "dataset_id"),
)
reading_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
site_id: Mapped[str] = mapped_column(
Text, ForeignKey("site.site_id", name="fk_reading_site", ondelete="RESTRICT")
)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True)
source: Mapped[str] = mapped_column(Text)
dataset_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey("dataset.dataset_id", name="fk_reading_dataset", ondelete="RESTRICT"),
)
consumption_kw: Mapped[float | None] = mapped_column(Double)
consumption_kwh: Mapped[float | None] = mapped_column(Double)
consumption_euros: Mapped[Decimal | None] = mapped_column(Numeric(14, 2))
voltage_v: Mapped[float | None] = mapped_column(Double)
current_a: Mapped[float | None] = mapped_column(Double)
power_factor: Mapped[float | None] = mapped_column(Double)
temperature_celsius: Mapped[float | None] = mapped_column(Double)
humidity_percent: Mapped[float | None] = mapped_column(Double)
solar_irradiance_wm2: Mapped[float | None] = mapped_column(Double)
is_working_hours: Mapped[bool | None] = mapped_column(Boolean)
data_quality: Mapped[str | None] = mapped_column(Text)
null_reasons: Mapped[list[str] | None] = mapped_column(ARRAY(Text))
imputed_values: Mapped[dict[str, Any] | None] = mapped_column(JSONB(none_as_null=True))
imputation_method: Mapped[str | None] = mapped_column(Text)
ingested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True))
Index(
"uq_reading_source",
Reading.site_id,
Reading.timestamp,
Reading.source,
func.coalesce(Reading.dataset_id, text("0")),
unique=True,
)
class Prediction(Base):
__tablename__ = "prediction"
__table_args__ = (
UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"),
Index("ix_prediction_site_target", "site_id", "target_at"),
CheckConstraint(
"target_metric IN ('consumption_kwh', 'consumption_kw')",
name="ck_prediction_metric",
),
CheckConstraint(
"period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period"
),
CheckConstraint(
"target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL",
name="ck_prediction_energy_period",
),
CheckConstraint(
"(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR "
"(status IN ('insufficient_data', 'error') AND predicted_value IS NULL "
"AND failure_reason IS NOT NULL)",
name="ck_prediction_status",
),
)
prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
site_id: Mapped[str] = mapped_column(
Text, ForeignKey("site.site_id", name="fk_prediction_site", ondelete="RESTRICT")
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
target_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
target_metric: Mapped[str] = mapped_column(Text)
period_minutes: Mapped[int | None] = mapped_column(Integer)
predicted_value: Mapped[float | None] = mapped_column(Double)
model_reference: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(Text)
failure_reason: Mapped[str | None] = mapped_column(Text)
class Alert(Base):
__tablename__ = "alert"
__table_args__ = (
UniqueConstraint("source", "site_id", "source_alert_id", name="uq_alert_source_reference"),
Index("ix_alert_site_timestamp", "site_id", "timestamp"),
ForeignKeyConstraint(
["prediction_id", "site_id"],
["prediction.prediction_id", "prediction.site_id"],
name="fk_alert_prediction_site",
ondelete="RESTRICT",
),
CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"),
CheckConstraint(
"type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type"
),
CheckConstraint(
"severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity"
),
)
alert_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
source_alert_id: Mapped[str] = mapped_column(Text)
site_id: Mapped[str] = mapped_column(
Text, ForeignKey("site.site_id", name="fk_alert_site", ondelete="RESTRICT")
)
source: Mapped[str] = mapped_column(Text)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True))
type: Mapped[str] = mapped_column(Text)
severity: Mapped[str] = mapped_column(Text)
message: Mapped[str] = mapped_column(Text)
value: Mapped[float | None] = mapped_column(Double)
threshold: Mapped[float | None] = mapped_column(Double)
metric: Mapped[str | None] = mapped_column(Text)
prediction_id: Mapped[int | None] = mapped_column(BigInteger)
raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True))
class Recommendation(Base):
__tablename__ = "recommendation"
__table_args__ = (
UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"),
)
recommendation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
alert_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("alert.alert_id", name="fk_recommendation_alert", ondelete="RESTRICT"),
)
action: Mapped[str] = mapped_column(Text)
explanation: Mapped[str] = mapped_column(Text)
rule_reference: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+44
View File
@@ -0,0 +1,44 @@
# Pourquoi : les tentatives vivent ici et non dans `audit_log`, qui est en ajout seul. Leur
# volume est piloté par l'attaquant : une force brute y écrirait des millions de lignes
# indestructibles. Cette table-ci se purge, et c'est aussi le compteur de la limitation.
# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 dirait
# qu'un compte existe.
import uuid
from datetime import datetime
from enum import StrEnum
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, String, Text, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class LoginOutcome(StrEnum):
SUCCES = "success"
IDENTIFIANTS_INVALIDES = "bad_credentials"
LIMITE = "throttled"
COMPTE_INDISPONIBLE = "inactive"
ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in LoginOutcome)
class LoginAttempt(Base):
__tablename__ = "login_attempt"
__table_args__ = (
CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_login_attempt_outcome"),
Index("ix_login_attempt_email_date", "email_tried", "occurred_at"),
Index("ix_login_attempt_ip_date", "client_ip", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
email_tried: Mapped[str] = mapped_column(String(320), nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
outcome: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
@@ -0,0 +1,27 @@
# Pourquoi : même séparation que `login_attempt` par rapport à `audit_log` : ce compteur est
# piloté par l'attaquant (une campagne de demandes) et se purge, l'audit log est en ajout seul.
# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 apprendrait
# qu'un compte existe.
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Identity, Index, String, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PasswordResetAttempt(Base):
__tablename__ = "password_reset_attempt"
__table_args__ = (
Index("ix_password_reset_attempt_email_date", "email_tried", "occurred_at"),
Index("ix_password_reset_attempt_ip_date", "client_ip", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
email_tried: Mapped[str] = mapped_column(String(320), nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
@@ -0,0 +1,40 @@
# Pourquoi : même schéma que `refresh_token` (chaîne opaque, jamais un JWT) pour la même
# raison : un jeton de réinitialisation doit être révocable d'un coup, et un JWT ne figure
# dans aucune ligne à invalider.
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, Text, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_token"
__table_args__ = (
Index("ix_password_reset_token_user", "user_id"),
Index(
"ix_password_reset_token_vivants",
"user_id",
postgresql_where="consumed_at is null",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
)
user_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
+64
View File
@@ -0,0 +1,64 @@
# Pourquoi : un jeton de rafraîchissement est une chaîne opaque, jamais un JWT. Il doit être
# révocable, donc cette ligne existe de toute façon ; le JWT n'ajouterait qu'un second chemin de
# signature. Surtout, la séparation devient structurelle : un JWT ne figure dans aucune ligne,
# une chaîne opaque échoue au décodage. Aucune confusion de type n'est possible.
# Piège : `expires_at` est absolu et hérité du prédécesseur à chaque rotation. S'il glissait,
# la promesse de sept jours serait fictive et une session active ne finirait jamais.
import uuid
from datetime import datetime
from enum import StrEnum
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, LargeBinary, Text, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class RevocationReason(StrEnum):
DECONNEXION = "logout"
ROTATION = "rotation"
REUTILISATION = "reuse_detected"
CHANGEMENT_MOT_DE_PASSE = "password_change"
ADMINISTRATION = "admin"
MOTIFS_AUTORISES = ", ".join(f"'{motif.value}'" for motif in RevocationReason)
class RefreshToken(Base):
__tablename__ = "refresh_token"
__table_args__ = (
CheckConstraint(
f"revoked_reason is null or revoked_reason in ({MOTIFS_AUTORISES})",
name="ck_refresh_token_revoked_reason",
),
Index("ix_refresh_token_family", "family_id"),
Index("ix_refresh_token_user", "user_id"),
Index(
"ix_refresh_token_vivants",
"user_id",
postgresql_where="revoked_at is null and rotated_at is null",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
)
family_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
replaced_by: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
+50
View File
@@ -0,0 +1,50 @@
# Contrainte : la table s'appelle `app_user` et non `user`, qui est un mot réservé PostgreSQL,
# raccourci de `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif, par
# opposition au rôle PostgreSQL qui porte, lui, le cantonnement des accès.
import uuid
from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, String, Text, func, text
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.roles import AccountKind, Role
from app.db.base import Base
ROLES_AUTORISES = ", ".join(f"'{role.value}'" for role in Role)
NATURES_AUTORISEES = ", ".join(f"'{nature.value}'" for nature in AccountKind)
class AppUser(Base):
__tablename__ = "app_user"
__table_args__ = (
CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"),
CheckConstraint(f"role in ({ROLES_AUTORISES})", name="ck_app_user_role"),
CheckConstraint(f"kind in ({NATURES_AUTORISEES})", name="ck_app_user_kind"),
)
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'human'"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
must_change_password: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("false")
)
# Une seule colonne couvre le changement de mot de passe, le changement de rôle et la
# désactivation : tout jeton émis avant cet instant est périmé.
credentials_changed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
full_name: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+55
View File
@@ -0,0 +1,55 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
class AlertRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> Sequence[Alert]:
requete = select(Alert).order_by(Alert.timestamp.desc(), Alert.alert_id.desc())
if site_id is not None:
requete = requete.where(Alert.site_id == site_id)
if severity is not None:
requete = requete.where(Alert.severity == severity)
return (await self._session.scalars(requete)).all()
async def create_many(self, alerts: Sequence[Alert]) -> Sequence[Alert]:
# `ON CONFLICT DO NOTHING` sur `uq_alert_source_reference` : rejouer la détection sur une
# fenêtre qui recouvre une exécution précédente ne doit pas dupliquer une alerte déjà
# enregistrée. `RETURNING` ne renvoie donc que les lignes effectivement insérées.
if not alerts:
return []
valeurs = [
{
"source_alert_id": alerte.source_alert_id,
"site_id": alerte.site_id,
"source": alerte.source,
"timestamp": alerte.timestamp,
"type": alerte.type,
"severity": alerte.severity,
"message": alerte.message,
"value": alerte.value,
"threshold": alerte.threshold,
"metric": alerte.metric,
"prediction_id": alerte.prediction_id,
"raw_data": alerte.raw_data,
}
for alerte in alerts
]
requete = (
insert(Alert)
.values(valeurs)
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert)
)
resultat = await self._session.execute(requete)
await self._session.flush()
return resultat.scalars().all()
@@ -0,0 +1,62 @@
# Piège : `detail` passe par une liste blanche de clés et jamais par un `dict(**kwargs)`. La
# table est en ajout seul : une clé inattendue qui porterait un secret ou une donnée
# personnelle ne pourrait plus en être retirée.
from collections.abc import Mapping
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.principal import Principal
from app.models.audit_log import AuditAction, AuditLog, AuditOutcome
CLES_DE_DETAIL_AUTORISEES = frozenset(
{
"email",
"role_avant",
"role_apres",
"famille",
"motif",
"source",
"sessions_revoquees",
}
)
def assemble_detail(brut: Mapping[str, Any] | None) -> dict[str, Any]:
if not brut:
return {}
return {cle: valeur for cle, valeur in brut.items() if cle in CLES_DE_DETAIL_AUTORISEES}
class AuditLogRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(
self,
*,
action: AuditAction,
outcome: AuditOutcome = AuditOutcome.SUCCES,
actor: Principal | None = None,
actor_label: str | None = None,
target_type: str | None = None,
target_id: str | None = None,
client_ip: str | None = None,
user_agent: str | None = None,
detail: Mapping[str, Any] | None = None,
) -> None:
self._session.add(
AuditLog(
actor_id=actor.id if actor else None,
actor_email=actor.email if actor else actor_label,
actor_role=actor.role.value if actor else None,
action=action.value,
target_type=target_type,
target_id=target_id,
outcome=outcome.value,
client_ip=client_ip,
user_agent=user_agent,
detail=assemble_detail(detail),
)
)
@@ -0,0 +1,67 @@
# Pourquoi : les trois compteurs tiennent en une seule requête, grâce aux clauses FILTER de
# PostgreSQL. Trois `count(*)` séparés feraient trois allers-retours sur le chemin critique de
# la connexion, qui est justement celui qu'un attaquant martèle.
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import UUID
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.login_attempt import LoginAttempt, LoginOutcome
@dataclass(frozen=True, slots=True)
class FailureCounts:
per_identifier_and_ip: int
per_ip: int
per_identifier: int
class LoginAttemptRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(
self,
*,
email: str,
client_ip: str | None,
outcome: LoginOutcome,
user_id: UUID | None = None,
) -> None:
self._session.add(
LoginAttempt(
email_tried=email.strip().lower(),
client_ip=client_ip,
outcome=outcome.value,
user_id=user_id,
)
)
async def count_recent_failures(
self, *, email: str, client_ip: str | None, window_seconds: int
) -> FailureCounts:
identifiant = email.strip().lower()
meme_email = LoginAttempt.email_tried == identifiant
meme_ip = LoginAttempt.client_ip == client_ip
requete = select(
func.count().filter(and_(meme_email, meme_ip)),
func.count().filter(meme_ip),
func.count().filter(meme_email),
).where(
LoginAttempt.outcome != LoginOutcome.SUCCES.value,
LoginAttempt.occurred_at > datetime.now(UTC) - timedelta(seconds=window_seconds),
meme_email | meme_ip,
)
par_identifiant_et_ip, par_ip, par_identifiant = (
await self._session.execute(requete)
).one()
return FailureCounts(
per_identifier_and_ip=par_identifiant_et_ip,
per_ip=par_ip,
per_identifier=par_identifiant,
)
@@ -0,0 +1,42 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.password_reset_attempt import PasswordResetAttempt
@dataclass(frozen=True, slots=True)
class ResetRequestCounts:
per_identifier: int
per_ip: int
class PasswordResetAttemptRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(self, *, email: str, client_ip: str | None) -> None:
self._session.add(
PasswordResetAttempt(email_tried=email.strip().lower(), client_ip=client_ip)
)
async def count_recent(
self, *, email: str, client_ip: str | None, window_seconds: int
) -> ResetRequestCounts:
identifiant = email.strip().lower()
meme_email = PasswordResetAttempt.email_tried == identifiant
meme_ip = PasswordResetAttempt.client_ip == client_ip
requete = select(
func.count().filter(meme_email),
func.count().filter(meme_ip),
).where(
PasswordResetAttempt.occurred_at
> datetime.now(UTC) - timedelta(seconds=window_seconds),
meme_email | meme_ip,
)
par_identifiant, par_ip = (await self._session.execute(requete)).one()
return ResetRequestCounts(per_identifier=par_identifiant, per_ip=par_ip)
@@ -0,0 +1,78 @@
# Piège : `consume()` est une seule instruction, sur le modèle de `claim_for_rotation()` du
# jeton de rafraîchissement. Un SELECT puis un UPDATE laisseraient une fenêtre où deux
# soumissions concurrentes du même lien réussiraient toutes les deux.
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.password_reset_token import PasswordResetToken
@dataclass(frozen=True, slots=True)
class ConsumedResetToken:
id: UUID
user_id: UUID
class PasswordResetTokenRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(
self,
*,
user_id: UUID,
token_hash: bytes,
expires_at: datetime,
client_ip: str | None,
user_agent: str | None,
) -> PasswordResetToken:
jeton = PasswordResetToken(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at,
client_ip=client_ip,
user_agent=user_agent,
)
self._session.add(jeton)
await self._session.flush()
return jeton
async def consume(self, token_hash: bytes) -> ConsumedResetToken | None:
requete = (
update(PasswordResetToken)
.where(
PasswordResetToken.token_hash == token_hash,
PasswordResetToken.consumed_at.is_(None),
PasswordResetToken.expires_at > func.clock_timestamp(),
)
.values(consumed_at=func.clock_timestamp())
.returning(PasswordResetToken.id, PasswordResetToken.user_id)
)
ligne = (await self._session.execute(requete)).one_or_none()
if ligne is None:
return None
return ConsumedResetToken(id=ligne.id, user_id=ligne.user_id)
# Piège : simple SELECT, volontairement pas atomique avec la consommation. Sert seulement
# au feedback UX (jeton encore valide ?) ; `consume()` reste la seule source de vérité.
async def exists_valid(self, token_hash: bytes) -> bool:
requete = select(PasswordResetToken.id).where(
PasswordResetToken.token_hash == token_hash,
PasswordResetToken.consumed_at.is_(None),
PasswordResetToken.expires_at > func.clock_timestamp(),
)
return (await self._session.execute(requete)).first() is not None
async def invalidate_all_for_user(self, user_id: UUID) -> int:
resultat = await self._session.execute(
update(PasswordResetToken)
.where(PasswordResetToken.user_id == user_id, PasswordResetToken.consumed_at.is_(None))
.values(consumed_at=func.clock_timestamp())
.returning(PasswordResetToken.id)
)
return len(resultat.all())
@@ -0,0 +1,49 @@
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Prediction
class PredictionRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_since(
self, *, since: datetime, site_id: str | None = None
) -> Sequence[Prediction]:
# Restreint à `available` : une prévision `insufficient_data`/`error` n'a pas de
# `predicted_value` à comparer à une lecture réelle (détection d'anomalie).
# Piège : `prediction` n'a pas d'unicité sur `(site_id, target_at)` (cf.
# `enervision_ml.score`, qui insère toujours une nouvelle ligne plutôt que d'écraser la
# précédente). `prediction_id` en dernier départage donc les égalités de `target_at` par
# ordre croissant : `_detect_anomaly` construit un dict qui garde le dernier rencontré,
# c'est-à-dire le run le plus récent plutôt qu'une ligne choisie au hasard par le plan
# d'exécution.
requete = (
select(Prediction)
.where(Prediction.target_at >= since, Prediction.status == "available")
.order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id)
)
if site_id is not None:
requete = requete.where(Prediction.site_id == site_id)
return (await self._session.scalars(requete)).all()
async def latest_by_site(self) -> Sequence[Prediction]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que
# `ReadingRepository.latest_by_site`. Trié sur `target_at` (couvert par
# `ix_prediction_site_target`) plutôt que `created_at` : c'est la prévision la plus
# récente qui compte pour un tableau de bord, pas forcément le dernier run de scoring.
requete = (
select(Prediction)
.distinct(Prediction.site_id)
.order_by(
Prediction.site_id,
Prediction.target_at.desc(),
Prediction.prediction_id.desc(),
)
)
return (await self._session.scalars(requete)).all()
+70
View File
@@ -0,0 +1,70 @@
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Reading
class ReadingRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def latest_by_site(self) -> Sequence[Reading]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage
# les égalités de timestamp, que `uq_reading_source` autorise à `source` différente.
requete = (
select(Reading)
.distinct(Reading.site_id)
.order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc())
)
return (await self._session.execute(requete)).scalars().all()
async def latest_for_site(self, site_id: str) -> Reading | None:
# Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la
# `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard.
requete = (
select(Reading)
.where(Reading.site_id == site_id)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(1)
)
lecture: Reading | None = await self._session.scalar(requete)
return lecture
async def list_since(self, *, since: datetime, site_id: str | None = None) -> Sequence[Reading]:
# Trié par site puis par heure croissante : la détection d'alertes (spike) a besoin de
# comparer chaque lecture à celle qui la précède immédiatement pour le même site.
# `reading_id` en dernier départage : `uq_reading_source` autorise deux lignes au même
# `site_id`+`timestamp` quand la `source` diffère (même piège que `latest_for_site`), sans
# quoi l'ordre entre elles ne serait pas garanti d'un appel à l'autre.
requete = (
select(Reading)
.where(Reading.timestamp >= since)
.order_by(Reading.site_id, Reading.timestamp, Reading.reading_id)
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return (await self._session.scalars(requete)).all()
async def list_history(
self,
*,
start: datetime,
end: datetime,
site_id: str | None = None,
limit: int,
offset: int,
) -> Sequence[Reading]:
requete = (
select(Reading)
.where(Reading.timestamp >= start, Reading.timestamp < end)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(limit)
.offset(offset)
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return (await self._session.scalars(requete)).all()
@@ -0,0 +1,51 @@
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Recommendation
@dataclass(frozen=True, slots=True)
class NouvelleRecommandation:
alert_id: int
action: str
explanation: str
rule_reference: str
TAILLE_DE_LOT = 1000
class RecommendationRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(self) -> Sequence[Recommendation]:
requete = select(Recommendation).order_by(Recommendation.recommendation_id)
return (await self._session.scalars(requete)).all()
async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
requete = select(Recommendation).where(
Recommendation.recommendation_id == recommendation_id
)
recommendation: Recommendation | None = await self._session.scalar(requete)
return recommendation
# Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une
# lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion.
async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int:
creees = 0
# Piège : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre
# colonnes. Au-delà de ce seuil un `INSERT` d'un seul tenant échouerait.
for debut in range(0, len(nouvelles), TAILLE_DE_LOT):
requete = (
insert(Recommendation)
.values([asdict(nouvelle) for nouvelle in nouvelles[debut : debut + TAILLE_DE_LOT]])
.on_conflict_do_nothing(constraint="uq_recommendation_alert_rule")
.returning(Recommendation.recommendation_id)
)
creees += len((await self._session.scalars(requete)).all())
return creees
@@ -0,0 +1,106 @@
# Piège : `claim_for_rotation()` est une seule instruction. Un SELECT puis un UPDATE
# laisseraient une fenêtre où deux onglets réussissent la même rotation. Zéro ligne retournée
# signifie donc, sans ambiguïté, que le jeton était déjà tourné, révoqué, expiré ou inconnu, et
# c'est `inspect()` qui départage ensuite ces cas.
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.refresh_token import RefreshToken, RevocationReason
@dataclass(frozen=True, slots=True)
class ClaimedToken:
id: UUID
family_id: UUID
user_id: UUID
expires_at: datetime
class RefreshTokenRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(
self,
*,
user_id: UUID,
family_id: UUID,
token_hash: bytes,
expires_at: datetime,
client_ip: str | None,
user_agent: str | None,
) -> RefreshToken:
jeton = RefreshToken(
user_id=user_id,
family_id=family_id,
token_hash=token_hash,
expires_at=expires_at,
client_ip=client_ip,
user_agent=user_agent,
)
self._session.add(jeton)
await self._session.flush()
return jeton
async def claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None:
requete = (
update(RefreshToken)
.where(
RefreshToken.token_hash == token_hash,
RefreshToken.rotated_at.is_(None),
RefreshToken.revoked_at.is_(None),
RefreshToken.expires_at > func.clock_timestamp(),
)
.values(
rotated_at=func.clock_timestamp(),
revoked_at=func.clock_timestamp(),
revoked_reason=RevocationReason.ROTATION.value,
)
.returning(
RefreshToken.id,
RefreshToken.family_id,
RefreshToken.user_id,
RefreshToken.expires_at,
)
)
ligne = (await self._session.execute(requete)).one_or_none()
if ligne is None:
return None
return ClaimedToken(
id=ligne.id,
family_id=ligne.family_id,
user_id=ligne.user_id,
expires_at=ligne.expires_at,
)
async def inspect(self, token_hash: bytes) -> RefreshToken | None:
requete = select(RefreshToken).where(RefreshToken.token_hash == token_hash)
return (await self._session.execute(requete)).scalar_one_or_none()
async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None:
await self._session.execute(
update(RefreshToken).where(RefreshToken.id == ancien_id).values(replaced_by=nouveau_id)
)
async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int:
resultat = await self._session.execute(
update(RefreshToken)
.where(RefreshToken.family_id == family_id, RefreshToken.revoked_at.is_(None))
.values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value)
.returning(RefreshToken.id)
)
return len(resultat.all())
async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int:
resultat = await self._session.execute(
update(RefreshToken)
.where(RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None))
.values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value)
.returning(RefreshToken.id)
)
return len(resultat.all())
+20
View File
@@ -0,0 +1,20 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Site
class SiteRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(self) -> Sequence[Site]:
requete = select(Site).order_by(Site.site_id)
return (await self._session.scalars(requete)).all()
async def get_by_id(self, site_id: str) -> Site | None:
requete = select(Site).where(Site.site_id == site_id)
site: Site | None = await self._session.scalar(requete)
return site
+97
View File
@@ -0,0 +1,97 @@
# Piège : `set_role()` et `set_active()` avancent `credentials_changed_at`. C'est ce qui rend
# un changement de rôle ou une désactivation effectifs à la requête suivante au lieu d'attendre
# l'expiration du jeton d'accès. Une mise à jour qui l'oublierait laisserait 15 minutes de
# privilèges périmés.
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import AccountKind, Role
from app.models.user import AppUser
class UserRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_email(self, email: str) -> AppUser | None:
requete = select(AppUser).where(AppUser.email == email.strip().lower())
return (await self._session.execute(requete)).scalar_one_or_none()
async def get_by_id(self, user_id: UUID) -> AppUser | None:
return await self._session.get(AppUser, user_id)
async def list_all(self) -> Sequence[AppUser]:
requete = select(AppUser).order_by(AppUser.email)
return (await self._session.execute(requete)).scalars().all()
async def count_active_admins(self) -> int:
requete = (
select(func.count())
.select_from(AppUser)
.where(AppUser.role == Role.ADMIN.value, AppUser.is_active.is_(True))
)
return (await self._session.execute(requete)).scalar_one()
async def create(
self,
*,
email: str,
password_hash: str,
role: Role,
kind: AccountKind = AccountKind.HUMAIN,
full_name: str | None = None,
must_change_password: bool = False,
) -> AppUser:
compte = AppUser(
email=email.strip().lower(),
password_hash=password_hash,
role=role.value,
kind=kind.value,
full_name=full_name,
must_change_password=must_change_password,
)
self._session.add(compte)
await self._session.flush()
return compte
async def update_password(
self, user_id: UUID, password_hash: str, *, must_change_password: bool
) -> None:
await self._session.execute(
update(AppUser)
.where(AppUser.id == user_id)
.values(
password_hash=password_hash,
must_change_password=must_change_password,
credentials_changed_at=func.clock_timestamp(),
)
)
async def rehash_password(self, user_id: UUID, password_hash: str) -> None:
# Un simple recalcul avec des paramètres Argon2 plus récents ne périme aucun jeton.
await self._session.execute(
update(AppUser).where(AppUser.id == user_id).values(password_hash=password_hash)
)
async def touch_last_login(self, user_id: UUID) -> None:
await self._session.execute(
update(AppUser).where(AppUser.id == user_id).values(last_login_at=func.now())
)
async def set_role(self, user_id: UUID, role: Role) -> None:
await self._session.execute(
update(AppUser)
.where(AppUser.id == user_id)
.values(role=role.value, credentials_changed_at=func.clock_timestamp())
)
async def set_active(self, user_id: UUID, *, is_active: bool) -> None:
await self._session.execute(
update(AppUser)
.where(AppUser.id == user_id)
.values(is_active=is_active, credentials_changed_at=func.clock_timestamp())
)
+34
View File
@@ -0,0 +1,34 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class AlertType(StrEnum):
SPIKE = "spike"
THRESHOLD = "threshold"
ANOMALY = "anomaly"
OUTAGE = "outage"
SENSOR = "sensor"
class AlertSeverity(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class AlertResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
alert_id: int
site_id: str
timestamp: datetime
type: AlertType
severity: AlertSeverity
message: str
value: float | None
threshold: float | None
metric: str | None
prediction_id: int | None
+95
View File
@@ -0,0 +1,95 @@
# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix
# mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur.
# Contrainte : `SPECIAL_CHARACTERS` doit rester identique à `password.validator.ts` côté
# frontend. `\w`/`\d` divergent entre Python (Unicode) et JavaScript (ASCII) : une classe
# explicite, plutôt qu'une négation, évite qu'un mot de passe soit accepté d'un côté et
# rejeté de l'autre (ex. "Sécurité1", où "é" comptait comme "spécial" pour Python seul).
import re
from typing import Literal, Self
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
PASSWORD_MIN_LENGTH = 8
PASSWORD_MAX_LENGTH = 128
SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.?"
_MAJUSCULE = re.compile(r"[A-ZÀ-ÖØ-Þ]")
_MINUSCULE = re.compile(r"[a-zà-öø-þ]")
_CHIFFRE = re.compile(r"[0-9]")
_SPECIAL = re.compile(r"[" + re.escape(SPECIAL_CHARACTERS) + r"]")
def valide_complexite(mot_de_passe: str) -> str:
manquants = [
nom
for nom, motif in (
("une majuscule", _MAJUSCULE),
("une minuscule", _MINUSCULE),
("un chiffre", _CHIFFRE),
("un caractère spécial", _SPECIAL),
)
if not motif.search(mot_de_passe)
]
if manquants:
raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}")
return mot_de_passe
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
@field_validator("new_password")
@classmethod
def _new_password_est_complexe(cls, valeur: str) -> str:
return valide_complexite(valeur)
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=1)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
@field_validator("new_password")
@classmethod
def _new_password_est_complexe(cls, valeur: str) -> str:
return valide_complexite(valeur)
class PrincipalResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
email: str
role: Role
kind: AccountKind
must_change_password: bool
@classmethod
def from_principal(cls, principal: Principal) -> Self:
return cls.model_validate(principal)
class ResetTokenValidationResponse(BaseModel):
valid: bool
class TokenResponse(BaseModel):
access_token: str
token_type: Literal["bearer"] = "bearer" # noqa: S105
expires_in: int
principal: PrincipalResponse
+23
View File
@@ -0,0 +1,23 @@
# Piège : ces modèles ne décrivent rien, ils publient. Ce sont eux que Swagger montre, donc ils
# doivent suivre `validation_error_handler()` et `unhandled_error_handler()` d'`app/api/errors.py`
# à la lettre. Un champ renommé là-bas sans l'être ici rend la documentation fausse en silence.
from pydantic import BaseModel
class ErrorResponse(BaseModel):
detail: str
class FieldError(BaseModel):
champ: str
type: str
class ValidationErrorResponse(BaseModel):
detail: list[FieldError]
class InternalErrorResponse(BaseModel):
detail: str
correlation: str
+4 -1
View File
@@ -10,7 +10,10 @@ class LivenessStatus(BaseModel):
environment: str
# Contrainte : la sonde ne publie pas la version de TimescaleDB. Une version exacte de
# composant, servie sans authentification, est de la reconnaissance gratuite pour qui
# cherche une CVE. Elle part dans le journal, où elle sert au diagnostic.
class ReadinessStatus(BaseModel):
status: Literal["ready"]
database: Literal["reachable"]
timescaledb: str
timescaledb: Literal["loaded"]
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class PredictionTargetMetric(StrEnum):
CONSUMPTION_KWH = "consumption_kwh"
CONSUMPTION_KW = "consumption_kw"
class PredictionStatus(StrEnum):
AVAILABLE = "available"
INSUFFICIENT_DATA = "insufficient_data"
ERROR = "error"
class SitePredictionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
target_at: datetime
target_metric: PredictionTargetMetric
period_minutes: int | None
predicted_value: float | None
status: PredictionStatus
failure_reason: str | None
model_reference: str
created_at: datetime
class SitePredictionSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
prediction: SitePredictionResponse | None
class PredictionSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
sites: list[SitePredictionSummaryResponse]
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict
class ReadingSource(StrEnum):
CSV = "csv"
API_CURRENT = "api_current"
API_HISTORY = "api_history"
class ReadingDataQuality(StrEnum):
GOOD = "good"
PARTIAL = "partial"
DEGRADED = "degraded"
CRITICAL = "critical"
class ReadingResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
reading_id: int
site_id: str
timestamp: datetime
source: ReadingSource
consumption_kw: float | None
consumption_kwh: float | None
# Piège : `Decimal` (miroir de `Numeric(14, 2)` en base, pour ne pas arrondir un montant)
# sérialise en chaîne dans le JSON, pas en nombre — un consommateur qui ferait un `parseFloat`
# naïf perdrait la précision que ce choix visait à garder.
consumption_euros: Decimal | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
solar_irradiance_wm2: float | None
is_working_hours: bool | None
data_quality: ReadingDataQuality | None
null_reasons: list[str] | None
imputed_values: dict[str, Any] | None
imputation_method: str | None
@@ -0,0 +1,20 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class RecommendationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
recommendation_id: int
alert_id: int
action: str
explanation: str
rule_reference: str
created_at: datetime
class RecommendationGenerationResponse(BaseModel):
alerts_examined: int
recommendations_created: int
already_present: int
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class SensorDiagnosticResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
status: Literal["ok", "failing"]
since: datetime | None = Field(
description=(
"Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la "
"panne : l'historique ne permet pas de le dater sans requête supplémentaire."
)
)
class SiteSensorsResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
consumption: SensorDiagnosticResponse
electrical: SensorDiagnosticResponse
temperature: SensorDiagnosticResponse
humidity: SensorDiagnosticResponse
network: SensorDiagnosticResponse
class SiteSensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
sensors: SiteSensorsResponse
overall: Literal["ok", "degraded", "critical"]
class SensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
sites: list[SiteSensorStatusResponse]
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
class SiteResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
site_type: str
location: str | None
capacity_kw: float | None
status: str | None
class SiteCurrentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: Literal["good", "partial", "degraded", "critical"]
+26
View File
@@ -0,0 +1,26 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
class SiteSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
current_consumption_kw: float | None
capacity_kw: float
load_percent: float | None
data_quality: Literal["good", "partial", "degraded", "critical"]
class StatsSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
total_sites: int
total_consumption_kw: float
total_capacity_kw: float
average_load_percent: float
sites: list[SiteSummaryResponse]
+41
View File
@@ -0,0 +1,41 @@
# Contrainte : les schémas de lecture et d'écriture sont séparés. Un modèle unique laisserait
# passer `role` ou `is_active` depuis un corps de requête, et renverrait `password_hash` en
# réponse. C'est l'attribution de masse, API3 du top 10 API.
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from app.core.roles import AccountKind, Role
class UserCreateRequest(BaseModel):
email: EmailStr
role: Role
full_name: str | None = Field(default=None, max_length=200)
class UserUpdateRequest(BaseModel):
role: Role | None = None
is_active: bool | None = None
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
email: str
role: Role
kind: AccountKind
is_active: bool
must_change_password: bool
full_name: str | None
last_login_at: datetime | None
created_at: datetime
class TemporaryPasswordResponse(BaseModel):
# Affiché une seule fois : l'empreinte seule est conservée côté serveur.
user: UserResponse
temporary_password: str
+323
View File
@@ -0,0 +1,323 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Alert, Prediction, Reading, Site
from app.repositories.alert import AlertRepository
from app.repositories.prediction import PredictionRepository
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
# Fenêtre de lectures/prédictions analysée à chaque exécution : assez large pour couvrir une paire
# de lectures consécutives (spike) et une coupure prolongée (outage), sans réanalyser tout
# l'historique à chaque lancement manuel du script de détection.
LOOKBACK = timedelta(hours=48)
# Cadence nominale d'une lecture : le CSV historique comme l'API Mock livrent un pas horaire.
EXPECTED_INTERVAL = timedelta(hours=1)
# Au-delà de trois pas manqués, on parle de coupure plutôt que d'un simple retard d'ingestion.
OUTAGE_THRESHOLD = EXPECTED_INTERVAL * 3
# +/-50% entre deux lectures consécutives du même site.
SPIKE_RELATIVE_THRESHOLD = 0.5
# 30% d'écart entre la consommation réelle et la prévision du même site/instant.
ANOMALY_RELATIVE_THRESHOLD = 0.3
# Une prévision quasi nulle rend l'écart relatif ininterprétable ; on l'ignore plutôt.
ANOMALY_MINIMUM_PREDICTED_VALUE = 1e-6
THRESHOLD_METRIC = "consumption_kw"
ANOMALY_METRIC = "consumption_kwh"
# `data_quality` -> sévérité du capteur défaillant. `good` est volontairement absent : il ne
# déclenche jamais d'alerte.
QUALITE_VERS_SEVERITE: dict[str, str] = {
"partial": "low",
"degraded": "medium",
"critical": "critical",
}
class AlertService:
def __init__(
self,
*,
alerts: AlertRepository,
readings: ReadingRepository,
predictions: PredictionRepository,
sites: SiteRepository,
) -> None:
self._alerts = alerts
self._readings = readings
self._predictions = predictions
self._sites = sites
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> Sequence[Alert]:
return await self._alerts.list_all(site_id=site_id, severity=severity)
async def detect(
self, *, now: datetime | None = None, site_id: str | None = None
) -> Sequence[Alert]:
"""Compare les lectures/prévisions récentes aux cinq règles internes et enregistre les
alertes déclenchées (`source='enervision'`). Idempotent grâce à `source_alert_id` :
rejouer sur une fenêtre déjà analysée ne recrée pas les mêmes lignes."""
instant = now or datetime.now(UTC)
depuis = instant - LOOKBACK
sites = await self._sites.list_all()
if site_id is not None:
sites = [site for site in sites if site.site_id == site_id]
sites_par_id = {site.site_id: site for site in sites}
if not sites_par_id:
return []
lectures = [
lecture
for lecture in await self._readings.list_since(since=depuis, site_id=site_id)
if lecture.site_id in sites_par_id
]
predictions = [
prediction
for prediction in await self._predictions.list_since(since=depuis, site_id=site_id)
if prediction.site_id in sites_par_id
]
dernieres_lectures = {
lecture.site_id: lecture
for lecture in await self._readings.latest_by_site()
if lecture.site_id in sites_par_id
}
candidates = [
*_detect_threshold(lectures, sites_par_id),
*_detect_spike(lectures),
*_detect_anomaly(lectures, predictions),
*_detect_outage(sites, dernieres_lectures, instant),
*_detect_sensor(lectures),
]
if not candidates:
return []
return await self._alerts.create_many(candidates)
def _severity_from_ratio(ratio: float) -> str:
if ratio >= 2.0:
return "critical"
if ratio >= 1.5:
return "high"
if ratio >= 1.2:
return "medium"
return "low"
def _detect_threshold(lectures: Sequence[Reading], sites_par_id: dict[str, Site]) -> list[Alert]:
# Seuil fixe = la capacité déclarée du site : dépasser `capacity_kw` est un dépassement
# matériel, pas une simple variation, et évite un seuil arbitraire non fourni par le domaine.
alertes = []
for lecture in lectures:
site = sites_par_id[lecture.site_id]
valeur = lecture.consumption_kw
if site.capacity_kw is None or site.capacity_kw <= 0 or valeur is None:
continue
if valeur <= site.capacity_kw:
continue
alertes.append(
Alert(
source_alert_id=f"threshold:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="threshold",
severity=_severity_from_ratio(valeur / site.capacity_kw),
message=(
f"Puissance appelée {valeur:.1f} kW au-dessus de la capacité du site "
f"({site.capacity_kw:.1f} kW)"
),
value=valeur,
threshold=site.capacity_kw,
metric=THRESHOLD_METRIC,
prediction_id=None,
raw_data={},
)
)
return alertes
def _detect_spike(lectures: Sequence[Reading]) -> list[Alert]:
# `lectures` est triée par site, heure puis `reading_id` (cf. `ReadingRepository.list_since`) :
# deux lignes consécutives du même site sont donc deux mesures consécutives dans le temps,
# sauf lorsqu'elles partagent le même horodatage (deux `source` différentes pour le même
# instant, permises par `uq_reading_source`) : ce n'est alors pas une variation réelle, on
# l'ignore plutôt que de générer une fausse alerte figée par son `source_alert_id`.
alertes = []
precedente: Reading | None = None
for lecture in lectures:
if (
precedente is None
or precedente.site_id != lecture.site_id
or precedente.timestamp == lecture.timestamp
):
precedente = lecture
continue
avant, apres = precedente.consumption_kw, lecture.consumption_kw
precedente = lecture
if avant is None or apres is None:
continue
if avant == 0:
# Une variation relative n'a pas de sens depuis zéro, mais un redémarrage direct à
# une consommation positive reste le signal le plus alarmant du lot : `critical`
# plutôt qu'un ratio indéfini.
if apres > 0:
alertes.append(_spike_alert(lecture, avant, apres, severity="critical"))
continue
variation = abs(apres - avant) / abs(avant)
if variation < SPIKE_RELATIVE_THRESHOLD:
continue
alertes.append(
_spike_alert(
lecture,
avant,
apres,
severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD),
)
)
return alertes
def _spike_alert(lecture: Reading, avant: float, apres: float, *, severity: str) -> Alert:
return Alert(
source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="spike",
severity=severity,
message=(
f"Variation brutale entre deux lectures consécutives ({avant:.1f} kW -> {apres:.1f} kW)"
),
value=apres,
threshold=avant,
metric=THRESHOLD_METRIC,
prediction_id=None,
raw_data={},
)
def _detect_anomaly(lectures: Sequence[Reading], predictions: Sequence[Prediction]) -> list[Alert]:
# Alignement strict (site_id, target_at == timestamp) : `enervision_ml.score` produit une
# cible à l'heure pile suivant la dernière lecture, sur la même grille horaire que `reading`.
predictions_par_cle = {
(prediction.site_id, prediction.target_at): prediction
for prediction in predictions
if prediction.target_metric == ANOMALY_METRIC
}
alertes = []
for lecture in lectures:
prediction = predictions_par_cle.get((lecture.site_id, lecture.timestamp))
reel = lecture.consumption_kwh
if prediction is None or reel is None or prediction.predicted_value is None:
continue
predite = prediction.predicted_value
if abs(predite) < ANOMALY_MINIMUM_PREDICTED_VALUE:
continue
ecart = abs(reel - predite) / abs(predite)
if ecart < ANOMALY_RELATIVE_THRESHOLD:
continue
alertes.append(
Alert(
source_alert_id=f"anomaly:{ANOMALY_METRIC}:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="anomaly",
severity=_severity_from_ratio(ecart / ANOMALY_RELATIVE_THRESHOLD),
message=(
f"Écart de {ecart * 100:.0f}% entre la consommation mesurée ({reel:.1f} kWh) "
f"et la prévision ({predite:.1f} kWh)"
),
value=reel,
threshold=predite,
metric=ANOMALY_METRIC,
prediction_id=prediction.prediction_id,
raw_data={},
)
)
return alertes
def _detect_outage(
sites: Sequence[Site], dernieres_lectures: dict[str, Reading], now: datetime
) -> list[Alert]:
alertes = []
for site in sites:
derniere = dernieres_lectures.get(site.site_id)
if derniere is None:
alertes.append(
_outage_alert(
site.site_id,
now,
reference=None,
message="Aucune lecture n'a jamais été reçue pour ce site",
severity="critical",
)
)
continue
absence = now - derniere.timestamp
if absence < OUTAGE_THRESHOLD:
continue
alertes.append(
_outage_alert(
site.site_id,
now,
reference=derniere.timestamp,
message=(
f"Aucune lecture depuis {absence} (dernière lecture : "
f"{derniere.timestamp.isoformat()})"
),
severity=_severity_from_ratio(absence / OUTAGE_THRESHOLD),
)
)
return alertes
def _outage_alert(
site_id: str, now: datetime, *, reference: datetime | None, message: str, severity: str
) -> Alert:
return Alert(
source_alert_id=f"outage:{reference.isoformat() if reference is not None else 'jamais'}",
site_id=site_id,
source="enervision",
timestamp=now,
type="outage",
severity=severity,
message=message,
value=None,
threshold=None,
metric=None,
prediction_id=None,
raw_data={},
)
def _detect_sensor(lectures: Sequence[Reading]) -> list[Alert]:
alertes = []
for lecture in lectures:
severite = QUALITE_VERS_SEVERITE.get(lecture.data_quality or "")
if severite is None:
continue
raisons = ", ".join(lecture.null_reasons or []) or "raison non précisée"
alertes.append(
Alert(
source_alert_id=f"sensor:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="sensor",
severity=severite,
message=f"Qualité de mesure {lecture.data_quality} ({raisons})",
value=None,
threshold=None,
metric=None,
prediction_id=None,
raw_data={},
)
)
return alertes
+464
View File
@@ -0,0 +1,464 @@
# Piège : les compteurs de limitation sont lus AVANT le hachage Argon2. Dans l'autre ordre,
# chaque requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la
# protection deviendrait l'amplificateur de déni de service qu'elle est censée empêcher.
# Piège : quand l'email est inconnu, `verify_dummy()` consomme le même temps qu'une
# vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence.
# Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée.
# `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction.
# Piège : dans `refresh()`, un jeton expiré ne révoque PAS la famille, un jeton déjà tourné si.
# La rotation ne protège de rien par elle-même : elle rend la réutilisation détectable, et
# c'est la détection qui termine le vol.
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import NoReturn, Protocol
from uuid import UUID, uuid4
from fastapi import BackgroundTasks
from app.core.hashing import Argon2Hasher
from app.core.logging import get_logger
from app.core.mailer import Mailer
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.core.security import (
TokenPolicy,
encode_access_token,
fingerprint_refresh,
generate_refresh_secret,
)
from app.models.audit_log import AuditAction, AuditOutcome
from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.user import UserRepository
logger = get_logger(__name__)
class Transaction(Protocol):
async def commit(self) -> None: ...
class AuthError(Exception):
pass
class InvalidCredentialsError(AuthError):
pass
class SessionRejectedError(AuthError):
pass
class RateLimitedError(AuthError):
def __init__(self, retry_after: int) -> None:
super().__init__("Trop de tentatives")
self.retry_after = retry_after
class InvalidOrExpiredResetTokenError(AuthError):
pass
@dataclass(frozen=True, slots=True)
class LoginPolicy:
window_seconds: int
max_failures_per_identifier_and_ip: int
max_failures_per_ip: int
max_failures_per_identifier: int
@dataclass(frozen=True, slots=True)
class PasswordResetPolicy:
window_seconds: int
max_requests_per_identifier: int
max_requests_per_ip: int
token_ttl: timedelta
frontend_reset_url: str
@dataclass(frozen=True, slots=True)
class AuthenticatedSession:
principal: Principal
access_token: str
expires_in: int
refresh_secret: str
class AuthService:
def __init__(
self,
*,
users: UserRepository,
attempts: LoginAttemptRepository,
refresh_tokens: RefreshTokenRepository,
audit: AuditLogRepository,
hasher: Argon2Hasher,
transaction: Transaction,
token_policy: TokenPolicy,
login_policy: LoginPolicy,
refresh_ttl: timedelta,
reset_tokens: PasswordResetTokenRepository,
reset_attempts: PasswordResetAttemptRepository,
reset_policy: PasswordResetPolicy,
mailer: Mailer,
) -> None:
self._users = users
self._attempts = attempts
self._refresh = refresh_tokens
self._audit = audit
self._hasher = hasher
self._transaction = transaction
self._token_policy = token_policy
self._login_policy = login_policy
self._refresh_ttl = refresh_ttl
self._reset_tokens = reset_tokens
self._reset_attempts = reset_attempts
self._reset_policy = reset_policy
self._mailer = mailer
async def authenticate(
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
) -> AuthenticatedSession:
await self._refuse_si_limite(email=email, client_ip=client_ip, user_agent=user_agent)
compte = await self._users.get_by_email(email)
if compte is None:
await self._hasher.verify_dummy()
await self._echoue(email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES)
if not await self._hasher.verify(compte.password_hash, password):
await self._echoue(
email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES, user_id=compte.id
)
if not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
await self._echoue(
email, client_ip, LoginOutcome.COMPTE_INDISPONIBLE, user_id=compte.id
)
if self._hasher.needs_rehash(compte.password_hash):
await self._users.rehash_password(compte.id, await self._hasher.hash(password))
await self._users.touch_last_login(compte.id)
await self._attempts.record(
email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id
)
secret = await self._ouvre_une_famille(
user_id=compte.id, client_ip=client_ip, user_agent=user_agent
)
await self._transaction.commit()
return self._session(self._en_principal(compte), secret)
async def refresh(
self, *, secret: str, client_ip: str | None, user_agent: str | None
) -> AuthenticatedSession:
empreinte = fingerprint_refresh(secret)
revendique = await self._refresh.claim_for_rotation(empreinte)
if revendique is None:
await self._traite_rotation_refusee(empreinte, client_ip, user_agent)
compte = await self._users.get_by_id(revendique.user_id)
if compte is None or not compte.is_active:
await self._refresh.revoke_family(revendique.family_id, RevocationReason.ADMINISTRATION)
await self._transaction.commit()
raise SessionRejectedError("Session révoquée")
nouveau_secret = generate_refresh_secret()
nouveau = await self._refresh.create(
user_id=revendique.user_id,
family_id=revendique.family_id,
token_hash=fingerprint_refresh(nouveau_secret),
expires_at=revendique.expires_at,
client_ip=client_ip,
user_agent=user_agent,
)
await self._refresh.link_replacement(revendique.id, nouveau.id)
await self._transaction.commit()
return self._session(self._en_principal(compte), nouveau_secret)
async def logout(self, *, secret: str) -> None:
ligne = await self._refresh.inspect(fingerprint_refresh(secret))
if ligne is not None:
await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION)
await self._transaction.commit()
async def change_password(
self,
*,
principal: Principal,
current_password: str,
new_password: str,
client_ip: str | None,
user_agent: str | None,
) -> AuthenticatedSession:
compte = await self._users.get_by_id(principal.id)
if compte is None or not await self._hasher.verify(compte.password_hash, current_password):
raise InvalidCredentialsError("Identifiants invalides")
await self._users.update_password(
principal.id, await self._hasher.hash(new_password), must_change_password=False
)
# Toutes les sessions tombent, puis on en rouvre une : l'appareil courant reste
# connecté et tous les autres sont déconnectés.
revoquees = await self._refresh.revoke_all_for_user(
principal.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE
)
secret = await self._ouvre_une_famille(
user_id=principal.id, client_ip=client_ip, user_agent=user_agent
)
await self._audit.record(
action=AuditAction.COMPTE_MOT_DE_PASSE_CHANGE,
actor=principal,
target_type="app_user",
target_id=str(principal.id),
client_ip=client_ip,
user_agent=user_agent,
detail={"sessions_revoquees": revoquees},
)
await self._transaction.commit()
rafraichi = await self._users.get_by_id(principal.id)
return self._session(self._en_principal(rafraichi or compte), secret)
async def request_password_reset(
self,
*,
email: str,
client_ip: str | None,
user_agent: str | None,
background_tasks: BackgroundTasks,
) -> None:
await self._refuse_si_limite_reset(email=email, client_ip=client_ip)
compte = await self._users.get_by_email(email)
# Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme
# `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte
# inconnu, compte inactif, ou email envoyé avec succès. L'envoi SMTP lui-même est différé
# en tâche de fond : le laisser dans le chemin de réponse rouvrirait le même oracle par le
# temps (aller-retour réseau) et par la forme (500 si le relais SMTP échoue, contre 202).
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
await self._hasher.verify_dummy()
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._transaction.commit()
return
await self._reset_tokens.invalidate_all_for_user(compte.id)
secret = generate_refresh_secret()
await self._reset_tokens.create(
user_id=compte.id,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + self._reset_policy.token_ttl,
client_ip=client_ip,
user_agent=user_agent,
)
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._audit.record(
action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE,
actor_label=compte.email,
target_type="app_user",
target_id=str(compte.id),
client_ip=client_ip,
user_agent=user_agent,
)
await self._transaction.commit()
lien = f"{self._reset_policy.frontend_reset_url}?token={secret}"
background_tasks.add_task(self._envoie_email_reset, compte.email, lien)
async def _envoie_email_reset(self, email: str, reset_url: str) -> None:
try:
await self._mailer.send_password_reset_email(to=email, reset_url=reset_url)
except Exception:
logger.exception("auth.password_reset.mail_failed")
# Piège : lecture seule, pas d'appel à `consume()`. Aucune limitation de débit n'est
# nécessaire ici : le jeton est un secret de 256 bits (`generate_refresh_secret`), donc
# non brute-forçable, et cette route n'apprend rien sur l'existence d'un compte ou d'un
# email, seulement si le lien déjà en main du visiteur est encore valide.
async def is_reset_token_valid(self, token: str) -> bool:
return await self._reset_tokens.exists_valid(fingerprint_refresh(token))
async def confirm_password_reset(
self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None
) -> AuthenticatedSession:
revendique = await self._reset_tokens.consume(fingerprint_refresh(token))
if revendique is None:
raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
# Piège : le jeton peut avoir été émis avant une désactivation du compte. Sans cette
# relecture, un lien encore valide (15 min) changerait quand même le mot de passe d'un
# compte désactivé, réutilisable dès sa réactivation.
compte = await self._users.get_by_id(revendique.user_id)
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
await self._users.update_password(
revendique.user_id, await self._hasher.hash(new_password), must_change_password=False
)
revoquees = await self._refresh.revoke_all_for_user(
revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE
)
secret = await self._ouvre_une_famille(
user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent
)
await self._audit.record(
action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI,
target_type="app_user",
target_id=str(revendique.user_id),
client_ip=client_ip,
user_agent=user_agent,
detail={"sessions_revoquees": revoquees},
)
await self._transaction.commit()
compte = await self._users.get_by_id(revendique.user_id)
if compte is None:
raise SessionRejectedError("Compte introuvable")
return self._session(self._en_principal(compte), secret)
async def logout_all(self, principal: Principal) -> int:
revoquees = await self._refresh.revoke_all_for_user(
principal.id, RevocationReason.DECONNEXION
)
await self._audit.record(
action=AuditAction.SESSIONS_REVOQUEES,
actor=principal,
detail={"sessions_revoquees": revoquees},
)
await self._transaction.commit()
return revoquees
def _session(self, principal: Principal, refresh_secret: str) -> AuthenticatedSession:
jeton = encode_access_token(
self._token_policy,
subject=principal.id,
role=principal.role.value,
kind=principal.kind.value,
)
return AuthenticatedSession(
principal=principal,
access_token=jeton,
expires_in=int(self._token_policy.access_ttl.total_seconds()),
refresh_secret=refresh_secret,
)
def _en_principal(self, compte: object) -> Principal:
return Principal(
id=compte.id, # type: ignore[attr-defined]
email=compte.email, # type: ignore[attr-defined]
role=Role(compte.role), # type: ignore[attr-defined]
kind=AccountKind(compte.kind), # type: ignore[attr-defined]
must_change_password=compte.must_change_password, # type: ignore[attr-defined]
)
async def _ouvre_une_famille(
self, *, user_id: UUID, client_ip: str | None, user_agent: str | None
) -> str:
secret = generate_refresh_secret()
await self._refresh.create(
user_id=user_id,
family_id=uuid4(),
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + self._refresh_ttl,
client_ip=client_ip,
user_agent=user_agent,
)
return secret
async def _traite_rotation_refusee(
self, empreinte: bytes, client_ip: str | None, user_agent: str | None
) -> NoReturn:
ligne = await self._refresh.inspect(empreinte)
if ligne is None:
raise SessionRejectedError("Session inconnue")
if ligne.expires_at <= datetime.now(UTC):
raise SessionRejectedError("Session expirée")
# Présenter un jeton déjà tourné est une preuve de compromission, pas un accident : toute
# la famille tombe, y compris la session encore vivante du voleur ou de la victime.
revoquees = await self._refresh.revoke_family(
ligne.family_id, RevocationReason.REUTILISATION
)
await self._audit.record(
action=AuditAction.REFRESH_REUTILISE,
outcome=AuditOutcome.ECHEC,
target_type="refresh_token",
target_id=str(ligne.family_id),
client_ip=client_ip,
user_agent=user_agent,
detail={"famille": str(ligne.family_id), "sessions_revoquees": revoquees},
)
await self._transaction.commit()
raise SessionRejectedError("Session révoquée")
async def _refuse_si_limite(
self, *, email: str, client_ip: str | None, user_agent: str | None
) -> None:
politique = self._login_policy
compteurs = await self._attempts.count_recent_failures(
email=email, client_ip=client_ip, window_seconds=politique.window_seconds
)
depasse = (
compteurs.per_identifier_and_ip >= politique.max_failures_per_identifier_and_ip
or compteurs.per_ip >= politique.max_failures_per_ip
or compteurs.per_identifier >= politique.max_failures_per_identifier
)
if not depasse:
return
await self._attempts.record(email=email, client_ip=client_ip, outcome=LoginOutcome.LIMITE)
# Un blocage déclenché par l'identifiant seul signe une attaque distribuée : lui seul
# mérite une trace durable, les échecs ordinaires restent dans `login_attempt`.
if compteurs.per_identifier >= politique.max_failures_per_identifier:
await self._audit.record(
action=AuditAction.LIMITE_PAR_IDENTIFIANT,
outcome=AuditOutcome.ECHEC,
actor_label=email.strip().lower(),
client_ip=client_ip,
user_agent=user_agent,
detail={"motif": "seuil par identifiant depasse"},
)
await self._transaction.commit()
raise RateLimitedError(politique.window_seconds)
async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None:
politique = self._reset_policy
compteurs = await self._reset_attempts.count_recent(
email=email, client_ip=client_ip, window_seconds=politique.window_seconds
)
depasse = (
compteurs.per_identifier >= politique.max_requests_per_identifier
or compteurs.per_ip >= politique.max_requests_per_ip
)
if not depasse:
return
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._transaction.commit()
raise RateLimitedError(politique.window_seconds)
async def _echoue(
self,
email: str,
client_ip: str | None,
outcome: LoginOutcome,
*,
user_id: UUID | None = None,
) -> NoReturn:
await self._attempts.record(
email=email, client_ip=client_ip, outcome=outcome, user_id=user_id
)
await self._transaction.commit()
raise InvalidCredentialsError("Identifiants invalides")
+18
View File
@@ -0,0 +1,18 @@
# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat
# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur
# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES`
# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli.
from typing import Literal, get_args
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality))
_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)}
def qualite_ou_critique(valeur: str | None) -> DataQuality:
if valeur is None:
return "critical"
return _PAR_VALEUR.get(valeur, "critical")
+68
View File
@@ -0,0 +1,68 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from app.models.energy import Prediction, Site
from app.repositories.prediction import PredictionRepository
from app.repositories.site import SiteRepository
@dataclass(frozen=True, slots=True)
class SitePrediction:
target_at: datetime
target_metric: str
period_minutes: int | None
predicted_value: float | None
status: str
failure_reason: str | None
model_reference: str
created_at: datetime
@dataclass(frozen=True, slots=True)
class SitePredictionSummary:
site_id: str
site_name: str
prediction: SitePrediction | None
@dataclass(frozen=True, slots=True)
class PredictionSummary:
timestamp: datetime
sites: list[SitePredictionSummary]
class PredictionService:
def __init__(self, sites: SiteRepository, predictions: PredictionRepository) -> None:
self._sites = sites
self._predictions = predictions
async def summary(self) -> PredictionSummary:
sites = await self._sites.list_all()
dernieres = {p.site_id: p for p in await self._predictions.latest_by_site()}
return PredictionSummary(
timestamp=datetime.now(UTC),
sites=[_resume_site(site, dernieres.get(site.site_id)) for site in sites],
)
def _resume_site(site: Site, derniere: Prediction | None) -> SitePredictionSummary:
# Piège : l'absence de ligne signifie « jamais scoré », pas une valeur pseudo-statut, qui
# n'existe pas dans la contrainte de la table. `prediction` reste `None` plutôt que de
# fabriquer un statut absent du domaine `available`/`insufficient_data`/`error`.
prediction = None
if derniere is not None:
prediction = SitePrediction(
target_at=derniere.target_at,
target_metric=derniere.target_metric,
period_minutes=derniere.period_minutes,
predicted_value=derniere.predicted_value,
status=derniere.status,
failure_reason=derniere.failure_reason,
model_reference=derniere.model_reference,
created_at=derniere.created_at,
)
return SitePredictionSummary(
site_id=site.site_id, site_name=site.site_name, prediction=prediction
)
+59
View File
@@ -0,0 +1,59 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Reading
from app.repositories.reading import ReadingRepository
FENETRE_PAR_DEFAUT = timedelta(hours=24)
FENETRE_MAXIMALE = timedelta(days=90)
class FenetreInverseeError(Exception):
"""`start` est postérieur ou égal à `end`."""
class FenetreTropLargeError(Exception):
"""L'écart entre `start` et `end` dépasse `FENETRE_MAXIMALE`."""
class ReadingService:
def __init__(self, *, readings: ReadingRepository) -> None:
self._readings = readings
async def list_history(
self,
*,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int,
offset: int,
) -> Sequence[Reading]:
debut, fin = self._resoudre_fenetre(start, end)
return await self._readings.list_history(
site_id=site_id, start=debut, end=fin, limit=limit, offset=offset
)
@staticmethod
def _resoudre_fenetre(
start: datetime | None, end: datetime | None
) -> tuple[datetime, datetime]:
# Piège : un datetime naïf (sans fuseau dans la chaîne ISO reçue) fait échouer la
# comparaison à `reading.timestamp` (`timestamptz`) au niveau du pilote, en 500 plutôt
# qu'un refus propre. On le traite comme de l'UTC plutôt que de le rejeter.
debut = _vers_utc(start)
fin = _vers_utc(end) or datetime.now(UTC)
if debut is None:
debut = fin - FENETRE_PAR_DEFAUT
if debut >= fin:
raise FenetreInverseeError
if fin - debut > FENETRE_MAXIMALE:
raise FenetreTropLargeError
return debut, fin
def _vers_utc(instant: datetime | None) -> datetime | None:
if instant is None:
return None
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
@@ -0,0 +1,62 @@
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from app.models.energy import Recommendation
from app.repositories.alert import AlertRepository
from app.repositories.recommendation import RecommendationRepository
from app.services.recommendation_rules import applique_les_regles
class Transaction(Protocol):
async def commit(self) -> None: ...
class RecommendationError(Exception):
pass
class RecommendationNotFoundError(RecommendationError):
pass
@dataclass(frozen=True, slots=True)
class RapportGeneration:
alertes_examinees: int
recommandations_creees: int
deja_presentes: int
class RecommendationService:
def __init__(
self,
*,
recommendations: RecommendationRepository,
alerts: AlertRepository,
transaction: Transaction,
) -> None:
self._recommendations = recommendations
self._alerts = alerts
self._transaction = transaction
async def list_all(self) -> Sequence[Recommendation]:
return await self._recommendations.list_all()
async def get_by_id(self, recommendation_id: int) -> Recommendation:
recommendation = await self._recommendations.get_by_id(recommendation_id)
if recommendation is None:
raise RecommendationNotFoundError(recommendation_id)
return recommendation
async def generate(self, *, site_id: str | None = None) -> RapportGeneration:
alertes = await self._alerts.list_all(site_id=site_id)
nouvelles = [nouvelle for alerte in alertes for nouvelle in applique_les_regles(alerte)]
creees = await self._recommendations.create_missing(nouvelles)
await self._transaction.commit()
return RapportGeneration(
alertes_examinees=len(alertes),
recommandations_creees=creees,
deja_presentes=len(nouvelles) - creees,
)
@@ -0,0 +1,117 @@
# Piège : `rule_reference` est la clé d'idempotence en base, portée par la contrainte
# `uq_recommendation_alert_rule`. Renommer une référence déjà livrée ne remplace pas les
# recommandations existantes, il en crée de nouvelles à côté. Une règle qui change de sens
# prend donc une référence suffixée `-v2` - REGLES.
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
from app.models.energy import Alert
from app.repositories.recommendation import NouvelleRecommandation
from app.schemas.alert import AlertSeverity, AlertType
FACTEUR_DEPASSEMENT_MAJEUR: Final = 1.2
POURCENTAGE_DEPASSEMENT_MAJEUR: Final = round((FACTEUR_DEPASSEMENT_MAJEUR - 1) * 100)
@dataclass(frozen=True, slots=True)
class Regle:
reference: str
action: str
declencheur: Callable[[Alert], bool]
motif: Callable[[Alert], str]
def _du_type(attendu: AlertType) -> Callable[[Alert], bool]:
return lambda alerte: alerte.type == attendu
def _de_severite(attendue: AlertSeverity) -> Callable[[Alert], bool]:
return lambda alerte: alerte.severity == attendue
# Un seuil nul ou négatif rendrait le rapport `value / threshold` arbitraire : l'alerte ne
# renseigne alors aucun dépassement exploitable, et la règle ne se déclenche pas.
def _depasse_largement_le_seuil(alerte: Alert) -> bool:
if alerte.value is None or alerte.threshold is None or alerte.threshold <= 0:
return False
return alerte.value >= alerte.threshold * FACTEUR_DEPASSEMENT_MAJEUR
REGLES: Final[tuple[Regle, ...]] = (
Regle(
reference="spike-delestage-v1",
action="Délester les équipements non prioritaires sur le créneau du pic",
declencheur=_du_type(AlertType.SPIKE),
motif=lambda alerte: f"Pic de consommation signalé sur le site {alerte.site_id}",
),
Regle(
reference="threshold-reduction-v1",
action="Ramener la puissance appelée sous le seuil contractuel",
declencheur=_du_type(AlertType.THRESHOLD),
motif=lambda alerte: f"Seuil de consommation dépassé sur le site {alerte.site_id}",
),
Regle(
reference="outage-secours-v1",
action="Basculer sur l'alimentation de secours et prévenir l'exploitant",
declencheur=_du_type(AlertType.OUTAGE),
motif=lambda alerte: (
f"Risque de surcharge ou de coupure imminente sur le site {alerte.site_id}"
),
),
Regle(
reference="sensor-maintenance-v1",
action="Planifier une intervention de maintenance sur le capteur",
declencheur=_du_type(AlertType.SENSOR),
motif=lambda alerte: (
f"Capteur défaillant sur le site {alerte.site_id}, les mesures ne sont plus fiables"
),
),
Regle(
reference="anomaly-verification-v1",
action="Confronter la mesure à la prévision et vérifier le paramétrage du site",
declencheur=_du_type(AlertType.ANOMALY),
motif=lambda alerte: (
f"Écart anormal entre la mesure et le comportement attendu du site {alerte.site_id}"
),
),
Regle(
reference="escalade-astreinte-v1",
action="Escalader à l'astreinte sous une heure",
declencheur=_de_severite(AlertSeverity.CRITICAL),
motif=lambda alerte: f"Alerte de sévérité critique sur le site {alerte.site_id}",
),
Regle(
reference="contrat-puissance-v1",
action="Réévaluer la puissance souscrite au contrat",
declencheur=_depasse_largement_le_seuil,
motif=lambda alerte: (
f"Dépassement d'au moins {POURCENTAGE_DEPASSEMENT_MAJEUR} % du seuil "
f"sur le site {alerte.site_id}"
),
),
)
def applique_les_regles(alerte: Alert) -> list[NouvelleRecommandation]:
contexte = _contexte_de_mesure(alerte)
return [
NouvelleRecommandation(
alert_id=alerte.alert_id,
action=regle.action,
explanation=f"{regle.motif(alerte)}{contexte}.",
rule_reference=regle.reference,
)
for regle in REGLES
if regle.declencheur(alerte)
]
def _contexte_de_mesure(alerte: Alert) -> str:
if alerte.value is None:
return ""
grandeur = alerte.metric or "valeur"
if alerte.threshold is None:
return f" ({grandeur} mesurée à {alerte.value})"
return f" ({grandeur} mesurée à {alerte.value}, seuil {alerte.threshold})"
+136
View File
@@ -0,0 +1,136 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import qualite_ou_critique
CapteurStatus = Literal["ok", "failing"]
OverallStatus = Literal["ok", "degraded", "critical"]
RAISON_VERS_CAPTEUR: dict[str, str] = {
"consumption_sensor_failure": "consumption",
"electrical_sensor_failure": "electrical",
"temperature_sensor_failure": "temperature",
"humidity_sensor_failure": "humidity",
"network_loss": "network",
}
CHAMPS_PAR_CAPTEUR: dict[str, tuple[str, ...]] = {
"consumption": ("consumption_kw",),
"electrical": ("voltage_v", "current_a", "power_factor"),
"temperature": ("temperature_celsius",),
"humidity": ("humidity_percent",),
}
@dataclass(frozen=True, slots=True)
class DiagnosticCapteur:
status: CapteurStatus
since: datetime | None
@dataclass(frozen=True, slots=True)
class SanteCapteurs:
consumption: DiagnosticCapteur
electrical: DiagnosticCapteur
temperature: DiagnosticCapteur
humidity: DiagnosticCapteur
network: DiagnosticCapteur
@dataclass(frozen=True, slots=True)
class SanteSite:
site_id: str
site_name: str
sensors: SanteCapteurs
overall: OverallStatus
@dataclass(frozen=True, slots=True)
class EtatCapteurs:
timestamp: datetime
sites: list[SanteSite]
class SensorService:
def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def status(self) -> EtatCapteurs:
sites = await self._sites.list_all()
dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
return EtatCapteurs(
timestamp=datetime.now(UTC),
sites=[_sante_site(site, dernieres.get(site.site_id)) for site in sites],
)
def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
if derniere is None:
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=None),
overall="critical",
)
qualite = qualite_ou_critique(derniere.data_quality)
overall = _overall_depuis_qualite(qualite)
if overall == "critical":
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=derniere.timestamp),
overall="critical",
)
raisons_signalees = {
RAISON_VERS_CAPTEUR[raison]
for raison in (derniere.null_reasons or [])
if raison in RAISON_VERS_CAPTEUR
}
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=SanteCapteurs(
consumption=_diagnostic("consumption", derniere, raisons_signalees),
electrical=_diagnostic("electrical", derniere, raisons_signalees),
temperature=_diagnostic("temperature", derniere, raisons_signalees),
humidity=_diagnostic("humidity", derniere, raisons_signalees),
network=_diagnostic("network", derniere, raisons_signalees),
),
overall=overall,
)
def _overall_depuis_qualite(qualite: str) -> OverallStatus:
if qualite == "good":
return "ok"
if qualite in ("partial", "degraded"):
return "degraded"
return "critical"
def _diagnostic(capteur: str, derniere: Reading, raisons_signalees: set[str]) -> DiagnosticCapteur:
champs = CHAMPS_PAR_CAPTEUR.get(capteur, ())
en_echec = capteur in raisons_signalees or any(
getattr(derniere, champ) is None for champ in champs
)
return DiagnosticCapteur(
status="failing" if en_echec else "ok",
since=derniere.timestamp if en_echec else None,
)
def _tout_en_echec(since: datetime | None) -> SanteCapteurs:
echec = DiagnosticCapteur(status="failing", since=since)
return SanteCapteurs(
consumption=echec, electrical=echec, temperature=echec, humidity=echec, network=echec
)
+82
View File
@@ -0,0 +1,82 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from app.models.energy import Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import DataQuality, qualite_ou_critique
class SiteError(Exception):
pass
class SiteNotFoundError(SiteError):
pass
@dataclass(frozen=True, slots=True)
class SiteCurrentReading:
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: DataQuality
class SiteService:
def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def list_all(self) -> Sequence[Site]:
return await self._sites.list_all()
async def get_by_id(self, site_id: str) -> Site:
site = await self._sites.get_by_id(site_id)
if site is None:
raise SiteNotFoundError(site_id)
return site
async def current(self, site_id: str) -> SiteCurrentReading:
site = await self.get_by_id(site_id)
derniere = await self._readings.latest_for_site(site_id)
if derniere is None:
return SiteCurrentReading(
timestamp=None,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=None,
consumption_kwh=None,
voltage_v=None,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
null_reasons=[],
data_quality="critical",
)
return SiteCurrentReading(
timestamp=derniere.timestamp,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=derniere.consumption_kw,
consumption_kwh=derniere.consumption_kwh,
voltage_v=derniere.voltage_v,
current_a=derniere.current_a,
power_factor=derniere.power_factor,
temperature_celsius=derniere.temperature_celsius,
humidity_percent=derniere.humidity_percent,
null_reasons=derniere.null_reasons or [],
data_quality=qualite_ou_critique(derniere.data_quality),
)
+74
View File
@@ -0,0 +1,74 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
@dataclass(frozen=True, slots=True)
class SiteConsumption:
site_id: str
site_name: str
current_consumption_kw: float | None
capacity_kw: float
load_percent: float | None
data_quality: DataQuality
@dataclass(frozen=True, slots=True)
class ConsumptionSummary:
timestamp: datetime
total_sites: int
total_consumption_kw: float
total_capacity_kw: float
average_load_percent: float
sites: list[SiteConsumption]
class StatsService:
def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def summary(self) -> ConsumptionSummary:
sites = await self._sites.list_all()
dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
resumes = [self._resume_site(site, dernieres.get(site.site_id)) for site in sites]
consommation_totale = sum(r.current_consumption_kw or 0 for r in resumes)
capacite_totale = sum(r.capacity_kw for r in resumes)
return ConsumptionSummary(
timestamp=datetime.now(UTC),
total_sites=len(resumes),
total_consumption_kw=consommation_totale,
total_capacity_kw=capacite_totale,
average_load_percent=(
consommation_totale / capacite_totale * 100 if capacite_totale > 0 else 0
),
sites=resumes,
)
@staticmethod
def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
capacite = site.capacity_kw or 0
qualite: DataQuality = "critical"
consommation = None
if derniere is not None and derniere.data_quality in QUALITES_CONNUES:
qualite = qualite_ou_critique(derniere.data_quality)
consommation = derniere.consumption_kw
charge = (
consommation / capacite * 100 if consommation is not None and capacite > 0 else None
)
return SiteConsumption(
site_id=site.site_id,
site_name=site.site_name,
current_consumption_kw=consommation,
capacity_kw=capacite,
load_percent=charge,
data_quality=qualite,
)
+164
View File
@@ -0,0 +1,164 @@
# Piège : `change_role()` et `set_active()` refusent de toucher au dernier administrateur actif.
# Sans cette garde, un administrateur peut se rétrograder ou se désactiver lui-même, et plus
# personne ne peut administrer la plateforme sans repasser par `psql`.
import secrets
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from uuid import UUID
from app.core.hashing import Argon2Hasher
from app.core.principal import Principal
from app.core.roles import Role
from app.models.audit_log import AuditAction
from app.models.refresh_token import RevocationReason
from app.models.user import AppUser
from app.repositories.audit_log import AuditLogRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.user import UserRepository
LONGUEUR_MOT_DE_PASSE_TEMPORAIRE = 18
class Transaction(Protocol):
async def commit(self) -> None: ...
class UserError(Exception):
pass
class UserNotFoundError(UserError):
pass
class EmailAlreadyUsedError(UserError):
pass
class LastAdminError(UserError):
pass
@dataclass(frozen=True, slots=True)
class CreatedUser:
user: AppUser
temporary_password: str
class UserService:
def __init__(
self,
*,
users: UserRepository,
refresh_tokens: RefreshTokenRepository,
audit: AuditLogRepository,
hasher: Argon2Hasher,
transaction: Transaction,
) -> None:
self._users = users
self._refresh = refresh_tokens
self._audit = audit
self._hasher = hasher
self._transaction = transaction
async def list_all(self) -> Sequence[AppUser]:
return await self._users.list_all()
async def create(
self, *, actor: Principal, email: str, role: Role, full_name: str | None
) -> CreatedUser:
if await self._users.get_by_email(email) is not None:
raise EmailAlreadyUsedError(email)
provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE)
compte = await self._users.create(
email=email,
password_hash=await self._hasher.hash(provisoire),
role=role,
full_name=full_name,
must_change_password=True,
)
await self._audit.record(
action=AuditAction.COMPTE_CREE,
actor=actor,
target_type="app_user",
target_id=str(compte.id),
detail={"email": compte.email, "role_apres": role.value},
)
await self._transaction.commit()
return CreatedUser(user=compte, temporary_password=provisoire)
async def change_role(self, *, actor: Principal, user_id: UUID, role: Role) -> AppUser:
compte = await self._exige(user_id)
if compte.role == role.value:
return compte
await self._refuse_si_dernier_admin(compte, futur_role=role, futur_actif=compte.is_active)
avant = compte.role
await self._users.set_role(user_id, role)
await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION)
await self._audit.record(
action=AuditAction.COMPTE_ROLE_CHANGE,
actor=actor,
target_type="app_user",
target_id=str(user_id),
detail={"role_avant": avant, "role_apres": role.value},
)
await self._transaction.commit()
return await self._exige(user_id)
async def set_active(self, *, actor: Principal, user_id: UUID, is_active: bool) -> AppUser:
compte = await self._exige(user_id)
if compte.is_active == is_active:
return compte
await self._refuse_si_dernier_admin(
compte, futur_role=Role(compte.role), futur_actif=is_active
)
await self._users.set_active(user_id, is_active=is_active)
if not is_active:
await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION)
await self._audit.record(
action=AuditAction.COMPTE_ACTIVE if is_active else AuditAction.COMPTE_DESACTIVE,
actor=actor,
target_type="app_user",
target_id=str(user_id),
)
await self._transaction.commit()
return await self._exige(user_id)
async def reset_password(self, *, actor: Principal, user_id: UUID) -> CreatedUser:
compte = await self._exige(user_id)
provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE)
await self._users.update_password(
user_id, await self._hasher.hash(provisoire), must_change_password=True
)
await self._refresh.revoke_all_for_user(user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE)
await self._audit.record(
action=AuditAction.COMPTE_MOT_DE_PASSE_REINITIALISE,
actor=actor,
target_type="app_user",
target_id=str(user_id),
detail={"email": compte.email},
)
await self._transaction.commit()
return CreatedUser(user=await self._exige(user_id), temporary_password=provisoire)
async def _exige(self, user_id: UUID) -> AppUser:
compte = await self._users.get_by_id(user_id)
if compte is None:
raise UserNotFoundError(str(user_id))
return compte
async def _refuse_si_dernier_admin(
self, compte: AppUser, *, futur_role: Role, futur_actif: bool
) -> None:
etait_admin = compte.role == Role.ADMIN.value and compte.is_active
reste_admin = futur_role is Role.ADMIN and futur_actif
if not etait_admin or reste_admin:
return
if await self._users.count_active_admins() <= 1:
raise LastAdminError(str(compte.id))
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -6,13 +6,19 @@ requires-python = ">=3.14,<3.15"
dependencies = [
"fastapi>=0.141.1",
"uvicorn[standard]>=0.53.0",
"pydantic>=2.13.5",
"pydantic[email]>=2.13.5",
"pydantic-settings>=2.15.0",
"sqlalchemy[asyncio]>=2.0.52",
"asyncpg>=0.31.0",
"alembic>=1.20.0",
"prometheus-fastapi-instrumentator>=8.1.0",
"python-json-logger>=4.2.0",
"pyjwt>=2.10",
"argon2-cffi>=23.1",
"anyio>=4.0",
"aiosmtplib>=5.1.3",
"httpx>=0.28.1",
"pandas>=3.0.5",
]
[dependency-groups]
@@ -22,7 +28,7 @@ dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"httpx>=0.28.1",
"pandas-stubs>=3.0.5.260914",
]
[build-system]
@@ -57,7 +63,8 @@ select = [
ignore = ["B008"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]
# S105 à S107 signalent les secrets en dur, qui sont justement la matière des tests d'auth.
"tests/**/*.py" = ["S101", "S105", "S106", "S107"]
[tool.ruff.lint.isort]
known-first-party = ["app"]
+88
View File
@@ -0,0 +1,88 @@
# Pourquoi : classification unique des routes du contrat, lue par test_route_protection.py,
# test_openapi.py et test_matrice_acces.py. Trois listes séparées dérivaient auparavant chacune
# de leur côté, et deux entrées de ROUTES_A_ROLE ne correspondaient plus à aucune route sans que
# rien ne le signale.
# Piège : les trois ensembles doivent rester disjoints et couvrir tout le schéma. C'est
# `test_every_declared_route_is_classified` qui le vérifie, pas la relecture.
from typing import Final
from app.core.roles import Role
Route = tuple[str, str]
ROUTES_PUBLIQUES: Final[frozenset[Route]] = frozenset(
{
("GET", "/api/v1/health/live"),
("GET", "/api/v1/health/ready"),
("POST", "/api/v1/auth/login"),
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
("POST", "/api/v1/auth/logout"),
("POST", "/api/v1/auth/forgot-password"),
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
("POST", "/api/v1/auth/reset-password"),
# Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un
# `Principal`. Le jeton est un secret de 256 bits, non brute-forçable.
("GET", "/api/v1/auth/reset-password/validate"),
("GET", "/metrics"),
}
)
# Le cookie opaque porte seul l'autorisation : sans lui la route rend 401, mais aucun `Principal`
# n'est construit et `require_role` n'entre jamais en jeu.
ROUTE_COOKIE: Final[frozenset[Route]] = frozenset({("POST", "/api/v1/auth/refresh")})
# Authentifiées par `CurrentPrincipalDep` nu, donc hors de `require_role` et, avec lui, hors du
# refus `password_change_required`. Volontaire pour `/auth/password`, qui est la sortie de l'état
# provisoire ; subi pour `/auth/logout-all`, cf. test_matrice_acces.py.
ROUTES_SANS_ROLE: Final[frozenset[Route]] = frozenset(
{
("GET", "/api/v1/auth/me"),
("POST", "/api/v1/auth/password"),
("POST", "/api/v1/auth/logout-all"),
}
)
ROLE_MINIMUM: Final[dict[Route, Role]] = {
("GET", "/api/v1/sites"): Role.LECTEUR,
("GET", "/api/v1/sites/{site_id}"): Role.LECTEUR,
("GET", "/api/v1/sites/{site_id}/current"): Role.LECTEUR,
("GET", "/api/v1/alerts"): Role.LECTEUR,
("GET", "/api/v1/recommendations"): Role.LECTEUR,
("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR,
("POST", "/api/v1/recommendations/generate"): Role.ADMIN,
("GET", "/api/v1/stats/summary"): Role.LECTEUR,
("GET", "/api/v1/readings"): Role.LECTEUR,
("GET", "/api/v1/predictions"): Role.LECTEUR,
("GET", "/api/v1/sensors/status"): Role.ADMIN,
("GET", "/api/v1/users"): Role.ADMIN,
("POST", "/api/v1/users"): Role.ADMIN,
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
("POST", "/api/v1/users/{user_id}/password-reset"): Role.ADMIN,
}
# Piège : `{recommendation_id}` est typé `int` et `{user_id}` est un UUID. Une substitution
# uniforme par une chaîne quelconque rendrait 422 avant d'atteindre la garde de rôle, et le test
# passerait en prouvant autre chose que ce qu'il annonce.
SUBSTITUTIONS: Final[dict[str, str]] = {
"{user_id}": "00000000-0000-0000-0000-000000000000",
"{site_id}": "site-absent-du-jeu-de-donnees",
"{recommendation_id}": "999999999",
}
def chemin_concret(chemin: str) -> str:
for gabarit, valeur in SUBSTITUTIONS.items():
chemin = chemin.replace(gabarit, valeur)
return chemin
def routes_du_schema(schema: dict[str, object]) -> list[Route]:
chemins: dict[str, dict[str, object]] = schema["paths"] # type: ignore[assignment]
return [
(methode.upper(), chemin)
for chemin, operations in chemins.items()
for methode in operations
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
]
+137
View File
@@ -0,0 +1,137 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_alert_service, get_current_principal
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Alert
from app.schemas.alert import AlertSeverity
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def alert(alert_id: int = 1, site_id: str = "site-1", severity: str = "high") -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="enervision",
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
type="threshold",
severity=severity,
message="Dépassement du seuil configuré",
value=812.5,
threshold=720.0,
metric="consumption_kw",
prediction_id=None,
raw_data={},
)
class FauxService:
def __init__(self) -> None:
self.alert = alert()
self.appels: list[tuple[str | None, str | None]] = []
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> list[Alert]:
self.appels.append((site_id, severity))
return [self.alert]
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_alert_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_alert_service, None)
async def test_list_alerts_returns_the_alerts(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/alerts")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"alert_id": 1,
"site_id": "site-1",
"timestamp": "2026-09-16T00:00:00Z",
"type": "threshold",
"severity": "high",
"message": "Dépassement du seuil configuré",
"value": 812.5,
"threshold": 720.0,
"metric": "consumption_kw",
"prediction_id": None,
}
]
async def test_list_alerts_transmits_the_site_id_filter(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
service = servi()
await client.get("/api/v1/alerts?site_id=site-1")
assert service.appels == [("site-1", None)]
async def test_list_alerts_transmits_the_severity_filter(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
service = servi()
await client.get("/api/v1/alerts?severity=critical")
assert service.appels == [(None, AlertSeverity.CRITICAL)]
async def test_list_alerts_returns_422_for_an_unknown_severity(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/alerts?severity=invalide")
assert response.status_code == 422
async def test_list_alerts_returns_an_empty_list_when_there_is_nothing(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=[])
response = await client.get("/api/v1/alerts")
assert response.status_code == 200
assert response.json() == []

Some files were not shown because too many files have changed in this diff Show More