Compare commits

...
Author SHA1 Message Date
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 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 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
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 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
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
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
129 changed files with 14591 additions and 417 deletions
+36
View File
@@ -17,3 +17,39 @@ APP_LOG_LEVEL=INFO
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=
+6
View File
@@ -38,3 +38,9 @@ updates:
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@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
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@v4
- 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"
+84
View File
@@ -56,3 +56,87 @@ jobs:
# 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@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
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@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
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
+22 -26
View File
@@ -1,5 +1,4 @@
name: Frontend
# Pipeline à choix multiple
on:
push:
@@ -10,8 +9,9 @@ on:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
# Ordre de lancement des jobs
# build -> test -> sonarqube -> deploy
permissions:
contents: read
jobs:
build:
@@ -23,13 +23,25 @@ jobs:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test:
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
# 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:
@@ -39,30 +51,14 @@ jobs:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- run: npm test --watch=false --code-coverage --coverageReporters=lcov
- 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@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
sonarqube:
needs: [build, test]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download coverage
uses: actions/download-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+132
View File
@@ -0,0 +1,132 @@
name: SonarQube
on:
push:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- ".github/workflows/sonarqube.yml"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- ".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@v6
- uses: actions/setup-node@v6
with:
node-version: 24
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@v6
- uses: actions/setup-node@v6
with:
node-version: 24
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@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
build-back:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
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@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
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@v4
with:
name: backend-coverage
path: apps/backend/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4
with:
name: backend-coverage
path: apps/backend
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+6
View File
@@ -66,6 +66,12 @@ 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/
+76 -3
View File
@@ -1,17 +1,31 @@
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 install-backend install-frontend install-ml dev dev-backend dev-frontend \
.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-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: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML
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
@@ -22,6 +36,9 @@ install-frontend: ## Installe les dépendances du frontend
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 & \
@@ -74,9 +91,65 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du
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)
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
+23 -5
View File
@@ -21,8 +21,9 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
@@ -47,13 +48,15 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── 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/
│ ├── modules/ Modules reutilisables
│ └── environments/ Racines Terraform, une par environnement
├── 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
@@ -98,6 +101,21 @@ 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.
+4
View File
@@ -18,3 +18,7 @@ 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
+1
View File
@@ -113,6 +113,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| `/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 |
+21 -3
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
@@ -142,14 +147,27 @@ uv run pytest tests/api/test_health.py # un seul fichier
uv run pytest -k readiness # par motif de nom
```
## Trois fichiers à connaître avant de toucher à l'authentification
## 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 la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la
diff d'une pull request.
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
+22 -2
View File
@@ -27,6 +27,7 @@ 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
@@ -34,6 +35,7 @@ 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
@@ -178,14 +180,23 @@ SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
def get_alert_service(session: SessionDep) -> AlertService:
return AlertService(alerts=AlertRepository(session))
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))
return RecommendationService(
recommendations=RecommendationRepository(session),
alerts=AlertRepository(session),
transaction=session,
)
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
@@ -212,6 +223,15 @@ def get_sensor_service(session: SessionDep) -> SensorService:
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,
+8 -1
View File
@@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "recommendations",
"description": (
"Consultation des recommandations issues des alertes. Accessible à partir du rôle "
"`lecteur`."
"`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`."
),
},
{
@@ -83,6 +83,13 @@ TAGS: Final[list[dict[str, Any]]] = [
"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(
@@ -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)
@@ -1,13 +1,18 @@
from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, RecommendationServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
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 RecommendationResponse
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."},
@@ -38,3 +43,22 @@ async def get_recommendation(
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,
)
+4
View File
@@ -5,6 +5,7 @@ from app.api.v1.endpoints import (
alerts,
auth,
health,
predictions,
readings,
recommendations,
sensors,
@@ -34,3 +35,6 @@ api_router.include_router(
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
)
+31
View File
@@ -22,8 +22,11 @@ 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"
@@ -63,6 +66,22 @@ async def create_admin(
)
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.
@@ -109,6 +128,14 @@ def build_parser() -> argparse.ArgumentParser:
"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
@@ -152,6 +179,10 @@ def main(argv: list[str] | None = None) -> int:
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(
+5
View File
@@ -34,6 +34,11 @@ 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)
@@ -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())
+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()
+34
View File
@@ -1,6 +1,7 @@
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
@@ -19,3 +20,36 @@ class AlertRepository:
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,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()
+15
View File
@@ -34,6 +34,21 @@ class ReadingRepository:
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,
*,
@@ -1,11 +1,24 @@
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
@@ -20,3 +33,19 @@ class RecommendationRepository:
)
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
+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]
@@ -12,3 +12,9 @@ class RecommendationResponse(BaseModel):
explanation: str
rule_reference: str
created_at: datetime
class RecommendationGenerationResponse(BaseModel):
alerts_examined: int
recommendations_created: int
already_present: int
+311 -2
View File
@@ -1,14 +1,323 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Alert
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) -> None:
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
+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
)
+37 -1
View File
@@ -1,7 +1,15 @@
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):
@@ -12,9 +20,24 @@ 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) -> None:
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()
@@ -24,3 +47,16 @@ class RecommendationService:
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})"
File diff suppressed because it is too large Load Diff
+305 -1
View File
@@ -1453,6 +1453,90 @@
}
}
},
"/api/v1/recommendations/generate": {
"post": {
"tags": [
"recommendations"
],
"summary": "Génère les recommandations à partir des alertes",
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
"security": [
{
"Jeton d'accès": []
}
],
"parameters": [
{
"name": "site_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecommendationGenerationResponse"
}
}
}
},
"500": {
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InternalErrorResponse"
}
}
}
},
"401": {
"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=`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationErrorResponse"
}
}
}
}
}
}
},
"/api/v1/stats/summary": {
"get": {
"tags": [
@@ -1719,6 +1803,62 @@
}
]
}
},
"/api/v1/predictions": {
"get": {
"tags": [
"predictions"
],
"summary": "Dernière prédiction de consommation par site",
"operationId": "get_predictions_api_v1_predictions_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PredictionSummaryResponse"
}
}
}
},
"500": {
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InternalErrorResponse"
}
}
}
},
"401": {
"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=`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"Jeton d'accès": []
}
]
}
}
},
"components": {
@@ -1972,6 +2112,45 @@
],
"title": "PasswordChangeRequest"
},
"PredictionStatus": {
"type": "string",
"enum": [
"available",
"insufficient_data",
"error"
],
"title": "PredictionStatus"
},
"PredictionSummaryResponse": {
"properties": {
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"sites": {
"items": {
"$ref": "#/components/schemas/SitePredictionSummaryResponse"
},
"type": "array",
"title": "Sites"
}
},
"type": "object",
"required": [
"timestamp",
"sites"
],
"title": "PredictionSummaryResponse"
},
"PredictionTargetMetric": {
"type": "string",
"enum": [
"consumption_kwh",
"consumption_kw"
],
"title": "PredictionTargetMetric"
},
"PrincipalResponse": {
"properties": {
"id": {
@@ -2249,6 +2428,29 @@
],
"title": "ReadingSource"
},
"RecommendationGenerationResponse": {
"properties": {
"alerts_examined": {
"type": "integer",
"title": "Alerts Examined"
},
"recommendations_created": {
"type": "integer",
"title": "Recommendations Created"
},
"already_present": {
"type": "integer",
"title": "Already Present"
}
},
"type": "object",
"required": [
"alerts_examined",
"recommendations_created",
"already_present"
],
"title": "RecommendationGenerationResponse"
},
"RecommendationResponse": {
"properties": {
"recommendation_id": {
@@ -2518,6 +2720,104 @@
],
"title": "SiteCurrentResponse"
},
"SitePredictionResponse": {
"properties": {
"target_at": {
"type": "string",
"format": "date-time",
"title": "Target At"
},
"target_metric": {
"$ref": "#/components/schemas/PredictionTargetMetric"
},
"period_minutes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Period Minutes"
},
"predicted_value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Predicted Value"
},
"status": {
"$ref": "#/components/schemas/PredictionStatus"
},
"failure_reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Failure Reason"
},
"model_reference": {
"type": "string",
"title": "Model Reference"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"target_at",
"target_metric",
"period_minutes",
"predicted_value",
"status",
"failure_reason",
"model_reference",
"created_at"
],
"title": "SitePredictionResponse"
},
"SitePredictionSummaryResponse": {
"properties": {
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_name": {
"type": "string",
"title": "Site Name"
},
"prediction": {
"anyOf": [
{
"$ref": "#/components/schemas/SitePredictionResponse"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"site_id",
"site_name",
"prediction"
],
"title": "SitePredictionSummaryResponse"
},
"SiteResponse": {
"properties": {
"site_id": {
@@ -2960,7 +3260,7 @@
},
{
"name": "recommendations",
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
"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",
@@ -2973,6 +3273,10 @@
{
"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`."
}
]
}
+1 -1
View File
@@ -17,6 +17,7 @@ dependencies = [
"argon2-cffi>=23.1",
"anyio>=4.0",
"aiosmtplib>=5.1.3",
"httpx>=0.28.1",
"pandas>=3.0.5",
]
@@ -27,7 +28,6 @@ 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",
]
+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"}
]
@@ -0,0 +1,279 @@
# Pourquoi : la matrice rôle x route sur les routes réelles. `test_authorization.py` la joue déjà,
# mais contre une route jetable montée par une fixture, ce qui ne dit rien du niveau effectivement
# posé sur `/sites` ou `/users`. `ROLE_MINIMUM` (tests/api/acces.py) est la référence, et ce
# fichier est ce qui la confronte au comportement observé.
# Piège : l'assertion porte sur le refus de la garde, pas sur un 200. Un rôle suffisant peut
# légitimement recevoir 404 ou 422 selon les données ; ce qui compte est qu'il ne reçoive pas le
# 403 `Droits insuffisants`. Sans cette nuance, le test dépendrait du contenu de la base.
# Les tests `integration` en fin de fichier rejouent la même matrice avec de vrais jetons, donc en
# traversant le décodage du JWT et la relecture du compte, ce que l'override court-circuite.
import uuid
from collections.abc import AsyncIterator, Callable, Iterator
import pytest
from fastapi import FastAPI
from httpx import AsyncClient, Response
from sqlalchemy import text
from app.api.deps import get_current_principal
from app.core.hashing import build_hasher
from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least
from app.db.session import get_session, get_session_factory
from app.repositories.user import UserRepository
from tests.api.acces import ROLE_MINIMUM, chemin_concret
ROLES = [Role.LECTEUR, Role.OPERATEUR, Role.ADMIN]
IDS_DE_ROLE = ["lecteur", "operateur", "admin"]
REFUS_DE_DROITS = "Droits insuffisants"
REFUS_DE_MOT_DE_PASSE = "password_change_required"
MOT_DE_PASSE = "un-mot-de-passe-de-recette"
# `FakeSession` de tests/factories.py rend un unique objet pour les trois formes d'appel, ce qui
# suffit à un test d'endpoint ciblé mais pas à balayer 13 routes qui interrogent chacune la base
# à sa façon. Ce double rend un résultat vide quelle que soit la forme demandée, pour que la
# réponse observée vienne de la garde de rôle et jamais d'un double mal ajusté.
class ResultatVide:
def scalars(self) -> ResultatVide:
return self
def all(self) -> list[object]:
return []
def first(self) -> None:
return None
def one_or_none(self) -> None:
return None
def scalar_one_or_none(self) -> None:
return None
def mappings(self) -> ResultatVide:
return self
def __iter__(self) -> Iterator[object]:
return iter(())
class SessionMuette:
async def scalar(self, *_: object, **__: object) -> None:
return None
async def execute(self, *_: object, **__: object) -> ResultatVide:
return ResultatVide()
async def scalars(self, *_: object, **__: object) -> ResultatVide:
return ResultatVide()
async def get(self, *_: object, **__: object) -> None:
return None
async def flush(self) -> None:
return None
async def commit(self) -> None:
return None
async def rollback(self) -> None:
return None
def add(self, *_: object, **__: object) -> None:
return None
@pytest.fixture
def base_muette(app: FastAPI) -> None:
async def override() -> AsyncIterator[SessionMuette]:
yield SessionMuette()
app.dependency_overrides[get_session] = override
def principal(role: Role, *, must_change_password: bool = False) -> Principal:
return Principal(
id=uuid.uuid4(),
email=f"matrice-{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=must_change_password,
)
@pytest.fixture
def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]:
def installe(acteur: Principal) -> None:
app.dependency_overrides[get_current_principal] = lambda: acteur
yield installe
app.dependency_overrides.pop(get_current_principal, None)
async def appelle(client: AsyncClient, methode: str, chemin: str, **kwargs: object) -> Response:
return await client.request(methode, chemin_concret(chemin), json={}, **kwargs) # type: ignore[arg-type]
def motif_du_refus(response: Response) -> str | None:
if response.status_code != 403:
return None
detail = response.json().get("detail")
return detail if isinstance(detail, str) else None
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_role_below_the_minimum_is_refused_on_every_guarded_route(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
role: Role,
) -> None:
connecte(principal(role))
laissees_passer: list[tuple[str, str, int]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
if has_at_least(role, minimum):
continue
response = await appelle(client, methode, chemin)
if motif_du_refus(response) != REFUS_DE_DROITS:
laissees_passer.append((methode, chemin, response.status_code))
assert laissees_passer == []
# Le pendant du test précédent : sans lui, une garde posée trop haut, par exemple `AdminDep` sur
# `/sites`, ne ferait échouer aucun test du dépôt.
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_role_at_or_above_the_minimum_is_never_refused_by_the_guard(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
role: Role,
) -> None:
connecte(principal(role))
refusees: list[tuple[str, str]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
if not has_at_least(role, minimum):
continue
response = await appelle(client, methode, chemin)
if motif_du_refus(response) == REFUS_DE_DROITS:
refusees.append((methode, chemin))
assert refusees == []
async def test_a_pending_password_change_is_refused_on_every_guarded_route(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
) -> None:
connecte(principal(Role.ADMIN, must_change_password=True))
laissees_passer: list[tuple[str, str, int]] = []
for methode, chemin in ROLE_MINIMUM:
response = await appelle(client, methode, chemin)
if motif_du_refus(response) != REFUS_DE_MOT_DE_PASSE:
laissees_passer.append((methode, chemin, response.status_code))
assert laissees_passer == []
@pytest.fixture
async def comptes_par_role() -> AsyncIterator[dict[Role, str]]:
marque = uuid.uuid4().hex[:12]
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
empreinte = await hacheur.hash(MOT_DE_PASSE)
adresses = {role: f"matrice-{marque}-{role.value}@enervision.fr" for role in ROLES}
async with get_session_factory()() as session:
depot = UserRepository(session)
for role, email in adresses.items():
await depot.create(email=email, password_hash=empreinte, role=role)
await session.commit()
yield adresses
async with get_session_factory()() as session:
await session.execute(
text("delete from app_user where email like :motif"), {"motif": f"matrice-{marque}-%"}
)
await session.commit()
async def authentifie(client: AsyncClient, email: str) -> dict[str, str]:
reponse = await client.post(
"/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE}
)
assert reponse.status_code == 200, reponse.text
return {"Authorization": f"Bearer {reponse.json()['access_token']}"}
@pytest.mark.integration
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
comptes_par_role: dict[Role, str], client: AsyncClient, role: Role
) -> None:
entetes = await authentifie(client, comptes_par_role[role])
ecarts: list[tuple[str, str, int, str]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
response = await appelle(client, methode, chemin, headers=entetes)
refuse = motif_du_refus(response) == REFUS_DE_DROITS
if refuse is has_at_least(role, minimum):
ecarts.append((methode, chemin, response.status_code, response.text[:120]))
assert ecarts == []
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
@pytest.mark.integration
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank(
comptes_par_role: dict[Role, str], client: AsyncClient
) -> None:
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
divergences: list[tuple[str, str]] = []
for methode, chemin in ROLE_MINIMUM:
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
if cote_lecteur.status_code != cote_operateur.status_code:
divergences.append((methode, chemin))
assert divergences == []
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
# `must_change_password` que seul `require_role` applique. Comportement figé ici, pas corrigé.
@pytest.mark.integration
async def test_a_temporary_password_blocks_the_business_routes_but_not_logout_all(
client: AsyncClient,
) -> None:
marque = uuid.uuid4().hex[:12]
email = f"matrice-{marque}-provisoire@enervision.fr"
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
empreinte = await hacheur.hash(MOT_DE_PASSE)
async with get_session_factory()() as session:
await UserRepository(session).create(
email=email, password_hash=empreinte, role=Role.ADMIN, must_change_password=True
)
await session.commit()
try:
entetes = await authentifie(client, email)
sites = await client.get("/api/v1/sites", headers=entetes)
identite = await client.get("/api/v1/auth/me", headers=entetes)
fermeture = await client.post("/api/v1/auth/logout-all", headers=entetes)
assert motif_du_refus(sites) == REFUS_DE_MOT_DE_PASSE
assert identite.status_code == 200
assert fermeture.status_code == 204
finally:
async with get_session_factory()() as session:
await session.execute(text("delete from app_user where email = :e"), {"e": email})
await session.commit()
+6 -15
View File
@@ -8,6 +8,7 @@ from typing import Any
import pytest
from app import cli
from tests.api.acces import ROLE_MINIMUM
METHODES = {"get", "post", "patch", "put", "delete"}
@@ -24,21 +25,11 @@ ORIGINE_VERIFIEE = {
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
# `password_change_required`, pas seulement les routes `admin`.
ROUTES_A_ROLE = {
("GET", "/api/v1/users"),
("POST", "/api/v1/users"),
("PATCH", "/api/v1/users/{id}"),
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
("GET", "/api/v1/sites/{site_id}/current"),
("GET", "/api/v1/alerts"),
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
("GET", "/api/v1/readings"),
("GET", "/api/v1/sensors/status"),
}
# Piège : cette liste était recopiée ici, et deux de ses entrées portaient `{id}` là où le contrat
# expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et le test ci-dessous
# passait au vert sans rien vérifier sur ces deux routes. Elle est maintenant dérivée, et
# `test_every_declared_route_is_classified` interdit l'entrée morte.
ROUTES_A_ROLE = frozenset(ROLE_MINIMUM)
@pytest.fixture(scope="module")
@@ -0,0 +1,85 @@
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_current_principal, get_prediction_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.prediction import PredictionSummary, SitePrediction, SitePredictionSummary
TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def lecteur() -> Principal:
# Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE`
# de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec
# autre chose que sa valeur par défaut.
return Principal(
id=uuid4(),
email="lecteur@enervision.fr",
role=Role.LECTEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self) -> None:
self.resume = PredictionSummary(
timestamp=datetime.now(UTC),
sites=[
SitePredictionSummary(
site_id="SITE001",
site_name="Bureau Paris La Défense",
prediction=SitePrediction(
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=812.5,
status="available",
failure_reason=None,
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
),
),
SitePredictionSummary(site_id="SITE002", site_name="Usine Lyon", prediction=None),
],
)
async def summary(self) -> PredictionSummary:
return self.resume
@pytest.fixture
def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_prediction_service] = lambda: service
app.dependency_overrides[get_current_principal] = lambda: lecteur()
return service
yield installe
app.dependency_overrides.pop(get_prediction_service, None)
app.dependency_overrides.pop(get_current_principal, None)
async def test_get_predictions_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/predictions")
assert response.status_code == 200
corps = response.json()
premier, second = corps["sites"]
assert premier["site_id"] == "SITE001"
assert premier["prediction"]["predicted_value"] == 812.5
assert premier["prediction"]["status"] == "available"
assert second["site_id"] == "SITE002"
assert second["prediction"] is None
+59 -1
View File
@@ -10,7 +10,7 @@ from app.api.deps import get_current_principal, get_recommendation_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Recommendation
from app.services.recommendation import RecommendationNotFoundError
from app.services.recommendation import RapportGeneration, RecommendationNotFoundError
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
@@ -40,6 +40,7 @@ class FauxService:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self.recommendation = recommendation()
self.site_demande: str | None = None
async def list_all(self) -> list[Recommendation]:
return [self.recommendation]
@@ -49,6 +50,10 @@ class FauxService:
raise self._erreur
return self.recommendation
async def generate(self, *, site_id: str | None = None) -> RapportGeneration:
self.site_demande = site_id
return RapportGeneration(alertes_examinees=2, recommandations_creees=3, deja_presentes=1)
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
@@ -142,3 +147,56 @@ async def test_get_recommendation_returns_404_when_the_session_finds_nothing(
response = await client.get("/api/v1/recommendations/404")
assert response.status_code == 404
@pytest.fixture
def admin_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal(Role.ADMIN)
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi_en_admin(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_recommendation_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_recommendation_service, None)
async def test_generate_recommendations_returns_the_generation_report(
servi_en_admin: Callable[[], FauxService], client: AsyncClient
) -> None:
servi_en_admin()
response = await client.post("/api/v1/recommendations/generate")
assert response.status_code == 200
assert response.json() == {
"alerts_examined": 2,
"recommendations_created": 3,
"already_present": 1,
}
async def test_generate_recommendations_forwards_the_requested_site(
servi_en_admin: Callable[[], FauxService], client: AsyncClient
) -> None:
service = servi_en_admin()
await client.post("/api/v1/recommendations/generate", params={"site_id": "SITE002"})
assert service.site_demande == "SITE002"
async def test_generate_recommendations_refuses_a_reader(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.post("/api/v1/recommendations/generate")
assert response.status_code == 403
+37 -30
View File
@@ -1,6 +1,6 @@
# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier
# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et
# demande une justification au relecteur.
# `ROUTES_PUBLIQUES` dans `tests/api/acces.py`, ce qui apparaît en clair dans la diff d'une pull
# request et demande une justification au relecteur.
# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de
# dépendances. L'arbre 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.
@@ -11,58 +11,64 @@ import pytest
from fastapi import FastAPI
from httpx import AsyncClient
ROUTES_PUBLIQUES = 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"),
}
from tests.api.acces import (
ROLE_MINIMUM,
ROUTE_COOKIE,
ROUTES_PUBLIQUES,
ROUTES_SANS_ROLE,
Route,
chemin_concret,
routes_du_schema,
)
VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000"
STATUTS_DE_REFUS = {401, 403}
HORS_SCHEMA = {("GET", "/metrics")}
def routes_declarees(app: FastAPI) -> list[tuple[str, str]]:
def routes_declarees(app: FastAPI) -> list[Route]:
schema: dict[str, Any] = app.openapi()
return [
(methode.upper(), chemin)
for chemin, operations in schema["paths"].items()
for methode in operations
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
]
return routes_du_schema(schema)
def routes_protegees(app: FastAPI) -> list[tuple[str, str]]:
def routes_protegees(app: FastAPI) -> list[Route]:
return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES]
def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None:
declarees = set(routes_declarees(app)) | {("GET", "/metrics")}
declarees = set(routes_declarees(app)) | HORS_SCHEMA
inconnues = ROUTES_PUBLIQUES - declarees
assert inconnues == set()
# Sans lui, une route ajoutée sans être classée n'est vue par aucun test de rôle : elle hérite
# du seul contrôle anonyme, et une garde posée au mauvais niveau passe inaperçue.
def test_every_declared_route_is_classified(app: FastAPI) -> None:
classees = ROUTES_PUBLIQUES | ROUTE_COOKIE | ROUTES_SANS_ROLE | set(ROLE_MINIMUM)
non_classees = set(routes_declarees(app)) - classees
fantomes = classees - set(routes_declarees(app)) - HORS_SCHEMA
assert non_classees == set(), "classer la route dans tests/api/acces.py"
assert fantomes == set(), "entrée morte : la route n'existe plus sous ce chemin"
def test_the_four_classes_of_routes_stay_disjoint() -> None:
classes = [ROUTES_PUBLIQUES, ROUTE_COOKIE, ROUTES_SANS_ROLE, frozenset(ROLE_MINIMUM)]
for rang, classe in enumerate(classes):
for autre in classes[rang + 1 :]:
assert classe & autre == frozenset()
async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public(
app: FastAPI, client: AsyncClient
) -> None:
ouvertes: list[tuple[str, str, int]] = []
for methode, chemin in routes_protegees(app):
concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION)
response = await client.request(methode, concret, json={})
response = await client.request(methode, chemin_concret(chemin), json={})
if response.status_code not in STATUTS_DE_REFUS:
ouvertes.append((methode, chemin, response.status_code))
@@ -95,4 +101,5 @@ async def test_the_documentation_routes_are_public_by_design(
app: FastAPI, client: AsyncClient, chemin: str
) -> None:
response = await client.get(chemin)
assert response.status_code == 200
@@ -0,0 +1,834 @@
import json
import sys
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from httpx import AsyncClient, MockTransport, Request, Response
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
import app.etl.mock_api_import as mock_api_import
from app.etl.mock_api_import import (
MAX_SITES,
READING_INSERT,
SOURCE_HISTORY,
build_reading_batch,
build_reading_row,
build_site_row,
fetch_readings,
fetch_sites,
upsert_sites,
)
def make_site() -> dict[str, Any]:
return {
"site_id": "SITE001",
"site_type": "office",
"site_name": "Bureau Paris La Défense",
"location": "Paris, France",
"capacity_kw": 200,
"status": "active",
}
def make_reading() -> dict[str, Any]:
return {
"timestamp": "2024-06-15T12:00:00Z",
"site_id": "SITE001",
"site_type": "office",
"consumption_kw": 87.34,
"consumption_kwh": 87.34,
"voltage_v": 401.2,
"current_a": 132.5,
"power_factor": 0.923,
"temperature_celsius": 22.1,
"humidity_percent": 58.4,
"null_reasons": [],
"data_quality": "good",
}
async def test_fetch_sites_returns_sites() -> None:
def handler(request: Request) -> Response:
assert request.url.path == "/api/v1/sites"
return Response(
status_code=200,
json=[make_site()],
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
sites = await fetch_sites(client)
assert len(sites) == 1
assert sites[0]["site_id"] == "SITE001"
assert sites[0]["site_type"] == "office"
async def test_fetch_sites_rejects_non_list_response() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json={"unexpected": "payload"},
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match="La réponse /api/v1/sites doit être une liste",
):
await fetch_sites(client)
async def test_fetch_readings_sends_expected_query_parameters() -> None:
captured_params: dict[str, str] = {}
def handler(request: Request) -> Response:
nonlocal captured_params
captured_params = dict(request.url.params)
return Response(
status_code=200,
json=[make_reading()],
)
transport = MockTransport(handler)
start_time = datetime.fromisoformat("2024-06-15T12:00:00")
end_time = datetime.fromisoformat("2024-06-15T13:00:00")
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
readings = await fetch_readings(
client=client,
site_id="SITE001",
start_time=start_time,
end_time=end_time,
limit=60,
)
assert len(readings) == 1
assert captured_params["site_id"] == "SITE001"
assert captured_params["start_time"] == "2024-06-15T12:00:00"
assert captured_params["end_time"] == "2024-06-15T13:00:00"
assert captured_params["limit"] == "60"
async def test_fetch_readings_rejects_non_list_response() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json={"unexpected": "payload"},
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match="La réponse /api/v1/readings doit être une liste",
):
await fetch_readings(
client=client,
site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
)
async def test_fetch_readings_raises_on_http_error() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=404,
json={"detail": "Site non trouvé"},
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(httpx.HTTPStatusError):
await fetch_readings(
client=client,
site_id="SITE999",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
)
def test_build_reading_row_respects_database_contract() -> None:
reading = make_reading()
row = build_reading_row(reading)
assert row["site_id"] == "SITE001"
assert row["source"] == SOURCE_HISTORY
assert row["source"] == "api_history"
assert row["dataset_id"] is None
assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00")
assert row["consumption_kw"] == 87.34
assert row["consumption_kwh"] == 87.34
assert row["data_quality"] == "good"
assert row["null_reasons"] == []
assert row["imputed_values"] is None
assert row["imputation_method"] is None
def test_build_reading_row_keeps_null_values_and_quality() -> None:
reading = make_reading()
reading["consumption_kw"] = None
reading["consumption_kwh"] = None
reading["voltage_v"] = None
reading["current_a"] = None
reading["power_factor"] = None
reading["data_quality"] = "degraded"
reading["null_reasons"] = [
"consumption_sensor_failure",
"electrical_sensor_failure",
]
row = build_reading_row(reading)
assert row["consumption_kw"] is None
assert row["consumption_kwh"] is None
assert row["voltage_v"] is None
assert row["current_a"] is None
assert row["power_factor"] is None
assert row["data_quality"] == "degraded"
assert row["null_reasons"] == [
"consumption_sensor_failure",
"electrical_sensor_failure",
]
assert row["imputed_values"] is None
assert row["imputation_method"] is None
def test_build_reading_row_keeps_raw_source_data() -> None:
reading = make_reading()
row = build_reading_row(reading)
raw_data = json.loads(row["raw_data"])
assert raw_data == reading
def test_build_reading_batch_transforms_all_readings() -> None:
first = make_reading()
second = make_reading()
second["timestamp"] = "2024-06-15T12:01:00Z"
second["consumption_kw"] = 90.5
rows = build_reading_batch([first, second])
assert len(rows) == 2
assert rows[0]["site_id"] == "SITE001"
assert rows[0]["consumption_kw"] == 87.34
assert rows[1]["site_id"] == "SITE001"
assert rows[1]["consumption_kw"] == 90.5
def test_create_mock_api_client_requires_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = SimpleNamespace(
mock_api_username=None,
mock_api_password=None,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: settings,
)
with pytest.raises(
ValueError,
match="Les identifiants de l'API Mock ne sont pas configurés",
):
mock_api_import.create_mock_api_client()
async def test_create_mock_api_client_uses_configuration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
password = MagicMock()
password.get_secret_value.return_value = "test-password"
settings = SimpleNamespace(
mock_api_base_url="https://mock.test/",
mock_api_username="test-user",
mock_api_password=password,
mock_api_timeout_seconds=10.0,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: settings,
)
client = mock_api_import.create_mock_api_client()
try:
assert str(client.base_url) == "https://mock.test"
assert client.timeout.connect == 10.0
finally:
await client.aclose()
async def test_upsert_sites_with_empty_list_does_nothing() -> None:
connection = AsyncMock()
await upsert_sites(
connection,
[],
)
connection.execute.assert_not_awaited()
async def test_import_mock_api_history_dry_run_does_not_write(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def handler(request: Request) -> Response:
if request.url.path == "/api/v1/sites":
return Response(
status_code=200,
json=[make_site()],
)
if request.url.path == "/api/v1/readings":
return Response(
status_code=200,
json=[make_reading()],
)
return Response(status_code=404)
transport = MockTransport(handler)
client = AsyncClient(
transport=transport,
base_url="https://mock.test",
)
monkeypatch.setattr(
mock_api_import,
"create_mock_api_client",
lambda: client,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: SimpleNamespace(
database_url="postgresql+asyncpg://unused",
),
)
create_engine_mock = MagicMock()
monkeypatch.setattr(
mock_api_import,
"create_async_engine",
create_engine_mock,
)
await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
dry_run=True,
)
create_engine_mock.assert_not_called()
async def test_import_mock_api_history_loads_data(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def handler(request: Request) -> Response:
if request.url.path == "/api/v1/sites":
return Response(
status_code=200,
json=[make_site()],
)
if request.url.path == "/api/v1/readings":
return Response(
status_code=200,
json=[make_reading()],
)
return Response(status_code=404)
transport = MockTransport(handler)
client = AsyncClient(
transport=transport,
base_url="https://mock.test",
)
monkeypatch.setattr(
mock_api_import,
"create_mock_api_client",
lambda: client,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: SimpleNamespace(
database_url="postgresql+asyncpg://test:test@localhost/test",
),
)
connection = AsyncMock()
transaction_context = MagicMock()
transaction_context.__aenter__ = AsyncMock(
return_value=connection,
)
transaction_context.__aexit__ = AsyncMock(
return_value=None,
)
engine = MagicMock()
engine.begin.return_value = transaction_context
engine.dispose = AsyncMock()
create_engine_mock = MagicMock(
return_value=engine,
)
upsert_sites_mock = AsyncMock()
monkeypatch.setattr(
mock_api_import,
"create_async_engine",
create_engine_mock,
)
monkeypatch.setattr(
mock_api_import,
"upsert_sites",
upsert_sites_mock,
)
await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
dry_run=False,
)
create_engine_mock.assert_called_once_with(
"postgresql+asyncpg://test:test@localhost/test",
pool_pre_ping=True,
)
upsert_sites_mock.assert_awaited_once_with(
connection,
[make_site()],
)
connection.execute.assert_awaited_once()
engine.dispose.assert_awaited_once()
def test_parse_datetime_accepts_z_suffix() -> None:
result = mock_api_import.parse_datetime(
"2024-06-15T12:00:00Z",
)
assert result == datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
def test_parse_args_reads_cli_parameters(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T12:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"60",
"--dry-run",
],
)
args = mock_api_import.parse_args()
assert args.start_time == datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
assert args.end_time == datetime.fromisoformat(
"2024-06-15T13:00:00+00:00",
)
assert args.limit == 60
assert args.dry_run is True
def test_main_rejects_limit_out_of_bounds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T12:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"0",
],
)
with pytest.raises(
ValueError,
match="--limit doit être compris entre 1 et 1000",
):
mock_api_import.main()
def test_main_rejects_invalid_period(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T14:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"60",
],
)
with pytest.raises(
ValueError,
match="--start-time doit être antérieur à --end-time",
):
mock_api_import.main()
def test_main_runs_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
start_time = datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
end_time = datetime.fromisoformat(
"2024-06-15T13:00:00+00:00",
)
import_mock = AsyncMock()
monkeypatch.setattr(
mock_api_import,
"parse_args",
lambda: SimpleNamespace(
start_time=start_time,
end_time=end_time,
limit=60,
dry_run=True,
),
)
monkeypatch.setattr(
mock_api_import,
"import_mock_api_history",
import_mock,
)
mock_api_import.main()
import_mock.assert_awaited_once_with(
start_time=start_time,
end_time=end_time,
limit=60,
dry_run=True,
)
async def test_fetch_sites_rejects_a_response_above_the_cap() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json=[make_site() for _ in range(MAX_SITES + 1)],
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match=f"dépasse le plafond de {MAX_SITES} sites",
):
await fetch_sites(client)
async def test_fetch_readings_rejects_a_response_above_the_requested_limit() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json=[make_reading(), make_reading(), make_reading()],
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match="dépasse la limite demandée de 2",
):
await fetch_readings(
client=client,
site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=2,
)
def test_build_reading_row_neutralises_values_outside_physical_bounds() -> None:
reading = make_reading()
reading["power_factor"] = 42.0
reading["temperature_celsius"] = 1e30
reading["humidity_percent"] = -1.0
row = build_reading_row(reading)
assert row["power_factor"] is None
assert row["temperature_celsius"] is None
assert row["humidity_percent"] is None
assert row["null_reasons"] == [
"out_of_physical_bounds:power_factor",
"out_of_physical_bounds:temperature_celsius",
"out_of_physical_bounds:humidity_percent",
]
assert row["data_quality"] == "degraded"
assert json.loads(row["raw_data"])["power_factor"] == 42.0
def test_build_reading_row_rejects_a_measure_that_is_not_a_number() -> None:
reading = make_reading()
reading["consumption_kw"] = "87.34"
row = build_reading_row(reading)
assert row["consumption_kw"] is None
assert "out_of_physical_bounds:consumption_kw" in row["null_reasons"]
def test_build_reading_row_drops_a_quality_the_database_refuses() -> None:
reading = make_reading()
reading["data_quality"] = "unknown"
row = build_reading_row(reading)
assert row["data_quality"] is None
def test_build_reading_row_requires_an_identifier() -> None:
reading = make_reading()
del reading["site_id"]
with pytest.raises(
ValueError,
match="Champ site_id absent ou invalide",
):
build_reading_row(reading)
def test_build_site_row_keeps_only_the_expected_columns() -> None:
site = make_site()
site["unexpected"] = "valeur hostile"
site["capacity_kw"] = -5.0
site["status"] = 12
row = build_site_row(site)
assert set(row) == {
"site_id",
"site_type",
"site_name",
"location",
"capacity_kw",
"status",
}
assert row["capacity_kw"] is None
assert row["status"] is None
async def test_upsert_sites_sends_only_the_expected_columns() -> None:
connection = AsyncMock()
site = make_site()
site["unexpected"] = "valeur hostile"
await upsert_sites(
connection,
[site],
)
rows = connection.execute.await_args.args[1]
assert "unexpected" not in rows[0]
assert rows[0]["site_id"] == "SITE001"
@pytest.mark.integration
async def test_reading_insert_is_idempotent(
session: AsyncSession,
) -> None:
reading = make_reading()
row = build_reading_row(reading)
connection = await session.connection()
await upsert_sites(
connection,
[make_site()],
)
await session.execute(
READING_INSERT,
[row],
)
await session.execute(
READING_INSERT,
[row],
)
result = await session.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE site_id = :site_id
AND timestamp = :timestamp
AND source = :source
"""
),
{
"site_id": row["site_id"],
"timestamp": row["timestamp"],
"source": row["source"],
},
)
assert result.scalar_one() == 1
await session.rollback()
@pytest.mark.integration
async def test_out_of_bounds_reading_is_stored_neutralised(
session: AsyncSession,
) -> None:
reading = make_reading()
reading["power_factor"] = 42.0
row = build_reading_row(reading)
connection = await session.connection()
await upsert_sites(
connection,
[make_site()],
)
await session.execute(
READING_INSERT,
[row],
)
result = await session.execute(
text(
"""
SELECT power_factor, data_quality, null_reasons, raw_data ->> 'power_factor'
FROM reading
WHERE site_id = :site_id
AND timestamp = :timestamp
AND source = :source
"""
),
{
"site_id": row["site_id"],
"timestamp": row["timestamp"],
"source": row["source"],
},
)
stored = result.one()
await session.rollback()
assert stored[0] is None
assert stored[1] == "degraded"
assert stored[2] == ["out_of_physical_bounds:power_factor"]
assert stored[3] == "42.0"
@@ -89,3 +89,60 @@ async def test_list_all_returns_an_empty_list_when_there_is_nothing(
alertes = await depot.list_all(site_id=identifiant_site())
assert list(alertes) == []
def _alerte_a_inserer(*, site_id: str, source_alert_id: str) -> Alert:
return Alert(
source_alert_id=source_alert_id,
site_id=site_id,
source="enervision",
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
type="threshold",
severity="high",
message="Dépassement du seuil configuré",
value=812.5,
threshold=720.0,
metric="consumption_kw",
prediction_id=None,
raw_data={},
)
async def test_create_many_inserts_every_alert(session: AsyncSession) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
creees = await depot.create_many(
[
_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:a"),
_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:b"),
]
)
identifiants = [a.alert_id for a in creees]
await session.rollback()
assert len(identifiants) == 2
assert all(identifiant is not None for identifiant in identifiants)
async def test_create_many_skips_a_duplicate_source_alert_id(session: AsyncSession) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
await depot.create_many(
[_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")]
)
rejouees = await depot.create_many(
[_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")]
)
await session.rollback()
assert rejouees == []
async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession) -> None:
depot = AlertRepository(session)
creees = await depot.create_many([])
assert creees == []
@@ -0,0 +1,171 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Prediction
from app.repositories.prediction import PredictionRepository
from tests.repositories.test_site import creer as creer_site
from tests.repositories.test_site import identifiant as identifiant_site
pytestmark = pytest.mark.integration
async def creer_prediction(
session: AsyncSession, *, site_id: str, **overrides: object
) -> Prediction:
prediction = Prediction(
site_id=site_id,
target_at=overrides.get("target_at", datetime(2026, 9, 16, tzinfo=UTC)),
target_metric=overrides.get("target_metric", "consumption_kwh"),
period_minutes=overrides.get("period_minutes", 60),
predicted_value=overrides.get("predicted_value", 42.0),
model_reference=overrides.get("model_reference", "lightgbm-test"),
status=overrides.get("status", "available"),
failure_reason=overrides.get("failure_reason"),
)
session.add(prediction)
await session.flush()
return prediction
async def test_list_since_excludes_predictions_before_the_cutoff(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
dedans = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 16, tzinfo=UTC)
)
await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC)
)
resultats = await depot.list_since(
since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id
)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [dedans.prediction_id]
async def test_list_since_excludes_predictions_that_are_not_available(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
await creer_prediction(
session,
site_id=site.site_id,
target_at=datetime(2026, 9, 16, tzinfo=UTC),
status="insufficient_data",
predicted_value=None,
failure_reason="pas assez d'historique",
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
await session.rollback()
assert list(resultats) == []
async def test_list_since_breaks_a_target_at_tie_by_ascending_prediction_id(
session: AsyncSession,
) -> None:
# `prediction` n'a pas d'unicité sur `(site_id, target_at)` : deux runs de scoring sans
# nouvelle lecture entre-temps produisent deux lignes `available` à la même cible. Sans ce
# départage, `_detect_anomaly` retiendrait une ligne au hasard plutôt que le run le plus
# récent.
site = await creer_site(session)
depot = PredictionRepository(session)
cible = datetime(2026, 9, 16, tzinfo=UTC)
premier_run = await creer_prediction(
session, site_id=site.site_id, target_at=cible, predicted_value=10.0
)
second_run = await creer_prediction(
session, site_id=site.site_id, target_at=cible, predicted_value=20.0
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [premier_run.prediction_id, second_run.prediction_id]
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = PredictionRepository(session)
voulue = await creer_prediction(session, site_id=premier.site_id)
await creer_prediction(session, site_id=second.site_id)
resultats = await depot.list_since(
since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id
)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [voulue.prediction_id]
async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
ancienne = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC)
)
recente = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 15, tzinfo=UTC)
)
resultats = await depot.latest_by_site()
identifiants = [
p.prediction_id
for p in resultats
if p.prediction_id in (ancienne.prediction_id, recente.prediction_id)
]
await session.rollback()
assert identifiants == [recente.prediction_id]
async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = PredictionRepository(session)
voulue_premier = await creer_prediction(session, site_id=premier.site_id)
voulue_second = await creer_prediction(session, site_id=second.site_id)
resultats = await depot.latest_by_site()
identifiants = {p.site_id for p in resultats if p.site_id in (premier.site_id, second.site_id)}
await session.rollback()
assert identifiants == {voulue_premier.site_id, voulue_second.site_id}
async def test_latest_by_site_keeps_an_insufficient_data_prediction(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
voulue = await creer_prediction(
session,
site_id=site.site_id,
status="insufficient_data",
predicted_value=None,
failure_reason="pas assez d'historique",
)
resultats = await depot.latest_by_site()
identifiants = [p.prediction_id for p in resultats if p.site_id == site.site_id]
await session.rollback()
assert identifiants == [voulue.prediction_id]
async def test_latest_by_site_returns_an_empty_list_when_there_is_nothing(
session: AsyncSession,
) -> None:
depot = PredictionRepository(session)
resultats = [p for p in await depot.latest_by_site() if p.site_id == identifiant_site()]
assert resultats == []
@@ -155,6 +155,79 @@ async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
assert trouvee is None
async def test_list_since_orders_by_site_then_by_time_ascending(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
plus_recente = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC)
)
plus_ancienne = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [plus_ancienne.reading_id, plus_recente.reading_id]
async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
dedans = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC)
)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
resultats = await depot.list_since(
since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [dedans.reading_id]
async def test_list_since_breaks_a_timestamp_tie_by_ascending_reading_id(
session: AsyncSession,
) -> None:
# `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 ce départage, `_detect_spike` traiterait
# cette paire comme une variation réelle selon un ordre non garanti par le plan d'exécution.
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 16, tzinfo=UTC)
premiere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
)
seconde = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [premiere.reading_id, seconde.reading_id]
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = ReadingRepository(session)
voulue = await creer_lecture(session, site_id=premier.site_id)
await creer_lecture(session, site_id=second.site_id)
resultats = await depot.list_since(
since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [voulue.reading_id]
async def test_list_history_orders_the_readings_by_timestamp_descending(
session: AsyncSession,
) -> None:
@@ -5,7 +5,8 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert, Recommendation, Site
from app.repositories.recommendation import RecommendationRepository
from app.repositories import recommendation as module_recommendation
from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository
pytestmark = pytest.mark.integration
@@ -83,3 +84,59 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier(
await session.rollback()
assert identifiants == sorted(identifiants)
def nouvelle(alert_id: int, reference: str = "spike-delestage-v1") -> NouvelleRecommandation:
return NouvelleRecommandation(
alert_id=alert_id,
action="Délester les équipements non prioritaires",
explanation="Pic de consommation signalé.",
rule_reference=reference,
)
async def test_create_missing_inserts_the_proposals(session: AsyncSession) -> None:
depot = RecommendationRepository(session)
alert_id = await creer_alerte(session)
creees = await depot.create_missing(
[nouvelle(alert_id), nouvelle(alert_id, "escalade-astreinte-v1")]
)
await session.rollback()
assert creees == 2
async def test_create_missing_ignores_a_rule_already_held_for_the_alert(
session: AsyncSession,
) -> None:
depot = RecommendationRepository(session)
alert_id = await creer_alerte(session)
await depot.create_missing([nouvelle(alert_id)])
creees = await depot.create_missing([nouvelle(alert_id)])
await session.rollback()
assert creees == 0
async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSession) -> None:
creees = await RecommendationRepository(session).create_missing([])
assert creees == 0
async def test_create_missing_inserts_every_proposal_across_several_batches(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(module_recommendation, "TAILLE_DE_LOT", 2)
depot = RecommendationRepository(session)
alert_id = await creer_alerte(session)
propositions = [nouvelle(alert_id, f"regle-{index}-v1") for index in range(5)]
creees = await depot.create_missing(propositions)
enregistrees = [r for r in await depot.list_all() if r.alert_id == alert_id]
await session.rollback()
assert creees == 5
assert len(enregistrees) == 5
+408 -6
View File
@@ -1,7 +1,10 @@
from datetime import UTC, datetime
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from app.models.energy import Alert
from app.services.alert import AlertService
from app.services.alert import OUTAGE_THRESHOLD, AlertService, _severity_from_ratio
NOW = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def alert(
@@ -26,10 +29,36 @@ def alert(
)
@dataclass
class FauxSite:
site_id: str
capacity_kw: float | None = None
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime
consumption_kw: float | None = None
consumption_kwh: float | None = None
data_quality: str | None = None
null_reasons: list[str] | None = None
@dataclass
class FauxPrediction:
site_id: str
target_at: datetime
predicted_value: float | None
target_metric: str = "consumption_kwh"
prediction_id: int = 1
class FakeRepository:
def __init__(self, alerts: list[Alert]) -> None:
self._alerts = alerts
self.appels: list[tuple[str | None, str | None]] = []
self.crees: list[Alert] = []
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
@@ -37,19 +66,392 @@ class FakeRepository:
self.appels.append((site_id, severity))
return self._alerts
async def create_many(self, alerts: list[Alert]) -> list[Alert]:
self.crees = list(alerts)
return self.crees
@dataclass
class FauxDepotLectures:
depuis: list[FauxLecture] = field(default_factory=list)
dernieres: list[FauxLecture] = field(default_factory=list)
async def list_since(self, *, since: datetime, site_id: str | None = None) -> list[FauxLecture]:
return [lecture for lecture in self.depuis if site_id is None or lecture.site_id == site_id]
async def latest_by_site(self) -> list[FauxLecture]:
return self.dernieres
@dataclass
class FauxDepotPredictions:
predictions: list[FauxPrediction] = field(default_factory=list)
async def list_since(
self, *, since: datetime, site_id: str | None = None
) -> list[FauxPrediction]:
return [p for p in self.predictions if site_id is None or p.site_id == site_id]
@dataclass
class FauxDepotSites:
sites: list[FauxSite]
async def list_all(self) -> list[FauxSite]:
return self.sites
def service(
*,
sites: list[FauxSite],
lectures: list[FauxLecture] | None = None,
dernieres: list[FauxLecture] | None = None,
predictions: list[FauxPrediction] | None = None,
alerts: FakeRepository | None = None,
) -> tuple[AlertService, FakeRepository]:
depot_alertes = alerts or FakeRepository([])
dernieres_lectures = dernieres if dernieres is not None else (lectures or [])
return (
AlertService(
alerts=depot_alertes, # type: ignore[arg-type]
readings=FauxDepotLectures(depuis=lectures or [], dernieres=dernieres_lectures), # type: ignore[arg-type]
predictions=FauxDepotPredictions(predictions or []), # type: ignore[arg-type]
sites=FauxDepotSites(sites), # type: ignore[arg-type]
),
depot_alertes,
)
async def test_list_all_returns_the_repository_alerts() -> None:
service = AlertService(alerts=FakeRepository([alert(1), alert(2)]))
svc, _ = service(sites=[], alerts=FakeRepository([alert(1), alert(2)]))
alertes = await service.list_all()
alertes = await svc.list_all()
assert [a.alert_id for a in alertes] == [1, 2]
async def test_list_all_relays_the_filters_to_the_repository() -> None:
depot = FakeRepository([])
service = AlertService(alerts=depot)
svc, _ = service(sites=[], alerts=depot)
await service.list_all(site_id="site-1", severity="critical")
await svc.list_all(site_id="site-1", severity="critical")
assert depot.appels == [("site-1", "critical")]
async def test_detect_raises_a_threshold_alert_above_site_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=150.0)],
)
await svc.detect(now=NOW)
(candidate,) = depot.crees
assert candidate.type == "threshold"
assert candidate.severity == "high"
assert candidate.value == 150.0
assert candidate.threshold == 100.0
assert candidate.metric == "consumption_kw"
async def test_detect_ignores_a_reading_within_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=80.0)],
)
await svc.detect(now=NOW)
assert depot.crees == []
async def test_detect_ignores_threshold_when_the_site_has_no_declared_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=None)],
lectures=[FauxLecture("A", NOW, consumption_kw=9999.0)],
)
await svc.detect(now=NOW)
assert depot.crees == []
async def test_detect_raises_a_spike_alert_on_a_brutal_consecutive_variation() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "spike"]
assert candidate.value == 160.0
assert candidate.threshold == 100.0
assert candidate.timestamp == NOW
async def test_detect_ignores_a_moderate_consecutive_variation() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=110.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_never_compares_consecutive_readings_across_two_sites() -> None:
svc, depot = service(
sites=[FauxSite("A"), FauxSite("B")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=10.0),
FauxLecture("B", NOW, consumption_kw=1000.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_raises_an_anomaly_alert_far_from_the_matching_prediction() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=70.0)],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "anomaly"]
assert candidate.value == 100.0
assert candidate.threshold == 70.0
assert candidate.metric == "consumption_kwh"
assert candidate.prediction_id == 1
async def test_detect_ignores_a_reading_close_to_its_prediction() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=95.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
async def test_detect_ignores_a_prediction_whose_target_at_does_not_match_the_reading() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW - timedelta(hours=1), predicted_value=1.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
async def test_detect_keeps_the_most_recent_run_when_two_predictions_share_the_same_target() -> (
None
):
# `PredictionRepository.list_since` départage les égalités de `target_at` par `prediction_id`
# croissant : le repository fait donc déjà passer le run le plus récent en dernier dans la
# liste, et c'est ce dernier que le dict de `_detect_anomaly` doit retenir.
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[
FauxPrediction("A", target_at=NOW, predicted_value=100.0, prediction_id=1),
FauxPrediction("A", target_at=NOW, predicted_value=70.0, prediction_id=2),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "anomaly"]
assert candidate.threshold == 70.0
assert candidate.prediction_id == 2
async def test_detect_raises_an_outage_alert_past_the_threshold() -> None:
derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1)
svc, depot = service(
sites=[FauxSite("A")],
lectures=[],
dernieres=[FauxLecture("A", derniere)],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "outage"]
assert candidate.severity in {"low", "medium", "high", "critical"}
async def test_detect_ignores_a_site_still_within_the_outage_threshold() -> None:
derniere = NOW - OUTAGE_THRESHOLD + timedelta(minutes=1)
svc, depot = service(
sites=[FauxSite("A")],
lectures=[],
dernieres=[FauxLecture("A", derniere)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "outage"] == []
async def test_detect_raises_a_critical_outage_alert_for_a_site_never_read() -> None:
svc, depot = service(sites=[FauxSite("A")], lectures=[], dernieres=[])
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "outage"]
assert candidate.severity == "critical"
assert candidate.source_alert_id == "outage:jamais"
async def test_detect_raises_a_sensor_alert_on_a_degraded_reading() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, data_quality="critical", null_reasons=["missing:x"])],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "sensor"]
assert candidate.severity == "critical"
async def test_detect_ignores_a_good_quality_reading_for_the_sensor_rule() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, data_quality="good")],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "sensor"] == []
async def test_detect_scopes_to_a_single_site_when_asked() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0), FauxSite("B", capacity_kw=100.0)],
lectures=[
FauxLecture("A", NOW, consumption_kw=150.0),
FauxLecture("B", NOW, consumption_kw=150.0),
],
)
await svc.detect(now=NOW, site_id="A")
assert {a.site_id for a in depot.crees} == {"A"}
async def test_detect_returns_early_when_there_is_no_site() -> None:
svc, depot = service(sites=[])
resultat = await svc.detect(now=NOW)
assert resultat == []
assert depot.crees == []
async def test_detect_ignores_a_spike_pair_with_a_missing_measurement() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=None),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_ignores_a_reading_still_at_zero_after_a_previous_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0),
FauxLecture("A", NOW, consumption_kw=0.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_raises_a_critical_spike_when_a_site_restarts_from_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0),
FauxLecture("A", NOW, consumption_kw=50.0),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "spike"]
assert candidate.severity == "critical"
assert candidate.value == 50.0
assert candidate.threshold == 0.0
async def test_detect_ignores_a_spike_pair_sharing_the_same_timestamp() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW, consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_ignores_an_anomaly_when_the_prediction_is_near_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=5.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=0.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
def test_severity_from_ratio_covers_every_band() -> None:
assert _severity_from_ratio(1.0) == "low"
assert _severity_from_ratio(1.2) == "medium"
assert _severity_from_ratio(1.5) == "high"
assert _severity_from_ratio(2.0) == "critical"
async def test_detect_does_not_call_create_many_when_nothing_triggers() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=10.0, data_quality="good")],
)
resultat = await svc.detect(now=NOW)
assert resultat == []
assert depot.crees == []
@@ -0,0 +1,121 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from app.services.prediction import PredictionService
TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass
class FauxSite:
site_id: str
site_name: str
@dataclass
class FauxPrediction:
site_id: str
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
class FauxDepotSites:
def __init__(self, sites: list[FauxSite]) -> None:
self._sites = sites
async def list_all(self) -> list[FauxSite]:
return self._sites
class FauxDepotPredictions:
def __init__(self, predictions: list[FauxPrediction]) -> None:
self._predictions = predictions
async def latest_by_site(self) -> list[FauxPrediction]:
return self._predictions
def prediction_disponible(site_id: str = "A") -> FauxPrediction:
return FauxPrediction(
site_id=site_id,
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=812.5,
status="available",
failure_reason=None,
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
)
async def test_summary_attaches_the_latest_prediction_to_its_site() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.site_id == "A"
assert site.prediction is not None
assert site.prediction.predicted_value == 812.5
assert site.prediction.status == "available"
async def test_summary_leaves_prediction_none_for_a_site_never_scored() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([]), # type: ignore[arg-type]
)
resume = await service.summary()
assert resume.sites[0].prediction is None
async def test_summary_carries_an_insufficient_data_prediction_without_a_value() -> None:
insuffisante = FauxPrediction(
site_id="A",
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=None,
status="insufficient_data",
failure_reason="pas assez d'historique",
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
)
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([insuffisante]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.prediction is not None
assert site.prediction.status == "insufficient_data"
assert site.prediction.predicted_value is None
assert site.prediction.failure_reason == "pas assez d'historique"
async def test_summary_covers_every_site_even_with_a_single_prediction_in_the_repository() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A"), FauxSite("B", "Site B")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type]
)
resume = await service.summary()
par_site = {site.site_id: site for site in resume.sites}
assert par_site["A"].prediction is not None
assert par_site["B"].prediction is None
@@ -1,10 +1,14 @@
from collections.abc import Sequence
from datetime import UTC, datetime
import pytest
from app.models.energy import Recommendation
from app.models.energy import Alert, Recommendation
from app.repositories.recommendation import NouvelleRecommandation
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
def recommendation(recommendation_id: int = 1) -> Recommendation:
return Recommendation(
@@ -13,13 +17,33 @@ def recommendation(recommendation_id: int = 1) -> Recommendation:
action="Vérifier la consommation",
explanation="Pic détecté",
rule_reference="spike-v1",
created_at=datetime(2024, 1, 1, tzinfo=UTC),
created_at=MOMENT,
)
def alerte(alert_id: int = 1, site_id: str = "SITE001", severity: str = "high") -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="api_mock",
timestamp=MOMENT,
type="spike",
severity=severity,
message="Pic de consommation",
value=None,
threshold=None,
metric=None,
prediction_id=None,
raw_data={},
)
class FakeRepository:
def __init__(self, recommendations: list[Recommendation]) -> None:
def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None:
self._recommendations = recommendations
self._creees = creees
self.recues: list[NouvelleRecommandation] = []
async def list_all(self) -> list[Recommendation]:
return self._recommendations
@@ -29,27 +53,111 @@ class FakeRepository:
(r for r in self._recommendations if r.recommendation_id == recommendation_id), None
)
async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int:
self.recues = list(nouvelles)
return len(self.recues) if self._creees is None else self._creees
async def test_list_all_returns_the_repository_recommendations() -> None:
service = RecommendationService(
recommendations=FakeRepository([recommendation(1), recommendation(2)])
class FakeAlertRepository:
def __init__(self, alertes: list[Alert]) -> None:
self._alertes = alertes
self.site_demande: str | None = None
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> list[Alert]:
self.site_demande = site_id
if site_id is None:
return self._alertes
return [a for a in self._alertes if a.site_id == site_id]
class FakeTransaction:
def __init__(self) -> None:
self.commits = 0
async def commit(self) -> None:
self.commits += 1
def service(
recommendations: FakeRepository | None = None,
alerts: FakeAlertRepository | None = None,
transaction: FakeTransaction | None = None,
) -> RecommendationService:
return RecommendationService(
recommendations=recommendations or FakeRepository([]),
alerts=alerts or FakeAlertRepository([]),
transaction=transaction or FakeTransaction(),
)
recommendations = await service.list_all()
async def test_list_all_returns_the_repository_recommendations() -> None:
depot = FakeRepository([recommendation(1), recommendation(2)])
recommendations = await service(recommendations=depot).list_all()
assert [r.recommendation_id for r in recommendations] == [1, 2]
async def test_get_by_id_returns_the_matching_recommendation() -> None:
service = RecommendationService(recommendations=FakeRepository([recommendation(1)]))
trouve = await service.get_by_id(1)
trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1)
assert trouve.recommendation_id == 1
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
service = RecommendationService(recommendations=FakeRepository([]))
with pytest.raises(RecommendationNotFoundError):
await service.get_by_id(404)
await service().get_by_id(404)
async def test_generate_persists_one_proposal_per_triggered_rule() -> None:
depot = FakeRepository([])
rapport = await service(
recommendations=depot, alerts=FakeAlertRepository([alerte(severity="critical")])
).generate()
assert {n.rule_reference for n in depot.recues} == {
"spike-delestage-v1",
"escalade-astreinte-v1",
}
assert rapport.recommandations_creees == 2
async def test_generate_commits_once() -> None:
transaction = FakeTransaction()
await service(alerts=FakeAlertRepository([alerte()]), transaction=transaction).generate()
assert transaction.commits == 1
async def test_generate_restricts_the_alerts_to_the_requested_site() -> None:
alertes = FakeAlertRepository([alerte(1, site_id="SITE001"), alerte(2, site_id="SITE002")])
depot = FakeRepository([])
rapport = await service(recommendations=depot, alerts=alertes).generate(site_id="SITE002")
assert alertes.site_demande == "SITE002"
assert rapport.alertes_examinees == 1
assert {n.alert_id for n in depot.recues} == {2}
async def test_generate_reports_nothing_when_no_alert_matches() -> None:
rapport = await service().generate()
assert rapport.alertes_examinees == 0
assert rapport.recommandations_creees == 0
assert rapport.deja_presentes == 0
async def test_generate_counts_the_proposals_the_database_already_held() -> None:
depot = FakeRepository([], creees=0)
rapport = await service(
recommendations=depot, alerts=FakeAlertRepository([alerte()])
).generate()
assert rapport.recommandations_creees == 0
assert rapport.deja_presentes == 1
@@ -0,0 +1,142 @@
from datetime import UTC, datetime
import pytest
from app.models.energy import Alert
from app.services.recommendation_rules import FACTEUR_DEPASSEMENT_MAJEUR, applique_les_regles
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
def alerte(
*,
alert_id: int = 1,
type_alerte: str = "spike",
severity: str = "high",
value: float | None = None,
threshold: float | None = None,
metric: str | None = None,
site_id: str = "SITE001",
) -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="api_mock",
timestamp=MOMENT,
type=type_alerte,
severity=severity,
message="Alerte de test",
value=value,
threshold=threshold,
metric=metric,
prediction_id=None,
raw_data={},
)
@pytest.mark.parametrize(
("type_alerte", "attendue"),
[
("spike", "spike-delestage-v1"),
("threshold", "threshold-reduction-v1"),
("outage", "outage-secours-v1"),
("sensor", "sensor-maintenance-v1"),
("anomaly", "anomaly-verification-v1"),
],
ids=["pic", "seuil", "coupure", "capteur", "anomalie"],
)
def test_each_alert_type_yields_its_own_rule(type_alerte: str, attendue: str) -> None:
proposees = applique_les_regles(alerte(type_alerte=type_alerte))
assert [p.rule_reference for p in proposees] == [attendue]
def test_a_critical_alert_adds_the_escalation_rule() -> None:
proposees = applique_les_regles(alerte(severity="critical"))
assert "escalade-astreinte-v1" in {p.rule_reference for p in proposees}
@pytest.mark.parametrize("severity", ["low", "medium", "high"], ids=["faible", "moyenne", "haute"])
def test_a_non_critical_alert_does_not_escalate(severity: str) -> None:
proposees = applique_les_regles(alerte(severity=severity))
assert "escalade-astreinte-v1" not in {p.rule_reference for p in proposees}
def test_a_large_overshoot_adds_the_contract_rule() -> None:
proposees = applique_les_regles(
alerte(value=720.0 * FACTEUR_DEPASSEMENT_MAJEUR, threshold=720.0)
)
assert "contrat-puissance-v1" in {p.rule_reference for p in proposees}
def test_an_overshoot_below_the_factor_does_not_add_the_contract_rule() -> None:
proposees = applique_les_regles(alerte(value=800.0, threshold=720.0))
assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees}
@pytest.mark.parametrize(
("value", "threshold"),
[(None, 720.0), (900.0, None), (900.0, 0.0), (900.0, -10.0)],
ids=["sans mesure", "sans seuil", "seuil nul", "seuil negatif"],
)
def test_the_contract_rule_stays_silent_without_an_exploitable_threshold(
value: float | None, threshold: float | None
) -> None:
proposees = applique_les_regles(alerte(value=value, threshold=threshold))
assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees}
def test_the_explanation_quotes_the_measure_and_the_threshold() -> None:
proposees = applique_les_regles(alerte(value=812.5, threshold=720.0, metric="consumption_kw"))
assert "(consumption_kw mesurée à 812.5, seuil 720.0)" in proposees[0].explanation
def test_the_explanation_quotes_the_measure_alone_when_no_threshold_is_known() -> None:
proposees = applique_les_regles(alerte(value=812.5, metric="consumption_kw"))
assert "(consumption_kw mesurée à 812.5)" in proposees[0].explanation
def test_the_explanation_omits_the_measure_when_the_alert_carries_none() -> None:
proposees = applique_les_regles(alerte())
assert "(" not in proposees[0].explanation
def test_the_explanation_names_the_site() -> None:
proposees = applique_les_regles(alerte(site_id="SITE042"))
assert "SITE042" in proposees[0].explanation
def test_every_proposal_carries_the_alert_identifier() -> None:
proposees = applique_les_regles(alerte(alert_id=77, severity="critical"))
assert {p.alert_id for p in proposees} == {77}
def test_an_alert_never_yields_the_same_rule_twice() -> None:
proposees = applique_les_regles(
alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw")
)
assert len(proposees) == len({p.rule_reference for p in proposees})
def test_a_critical_alert_over_the_threshold_yields_the_three_rules() -> None:
proposees = applique_les_regles(
alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw")
)
assert {p.rule_reference for p in proposees} == {
"spike-delestage-v1",
"escalade-astreinte-v1",
"contrat-puissance-v1",
}
+27
View File
@@ -118,3 +118,30 @@ def test_main_exports_the_contract_without_asking_for_a_password(
assert code == 0
assert destination.exists()
assert str(destination) in capsys.readouterr().out
def test_build_parser_reads_the_generate_recommendations_arguments() -> None:
arguments = cli.build_parser().parse_args(["generate-recommendations", "--site-id", "SITE002"])
assert arguments.commande == "generate-recommendations"
assert arguments.site_id == "SITE002"
def test_build_parser_defaults_the_generation_to_every_site() -> None:
arguments = cli.build_parser().parse_args(["generate-recommendations"])
assert arguments.site_id is None
def test_main_generates_the_recommendations_without_asking_for_a_password(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
async def fausse_generation(*, site_id: str | None) -> str:
return f"génération lancée pour {site_id}"
monkeypatch.setattr(cli, "generate_recommendations", fausse_generation)
code = cli.main(["generate-recommendations", "--site-id", "SITE002"])
assert code == 0
assert "SITE002" in capsys.readouterr().out
@@ -0,0 +1,87 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session_factory
from app.detection import internal_alerts
from app.repositories.alert import AlertRepository
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
def test_parse_args_defaults_to_no_site_and_no_instant() -> None:
arguments = internal_alerts.parse_args([])
assert arguments.site_id is None
assert arguments.now is None
def test_parse_args_reads_the_site_id() -> None:
arguments = internal_alerts.parse_args(["--site-id", "site-1"])
assert arguments.site_id == "site-1"
def test_parse_args_parses_the_instant_option() -> None:
arguments = internal_alerts.parse_args(["--now", "2026-09-16T12:00:00+00:00"])
assert arguments.now == datetime(2026, 9, 16, 12, tzinfo=UTC)
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
assert internal_alerts._parse_instant("2026-09-16T12:00:00") == datetime(
2026, 9, 16, 12, tzinfo=UTC
)
def test_main_prints_how_many_alerts_were_recorded(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
async def fausse_execution(*, now: datetime | None, site_id: str | None) -> int:
return 3
monkeypatch.setattr(internal_alerts, "run_detection", fausse_execution)
code = internal_alerts.main([])
assert code == 0
assert "3 nouvelle" in capsys.readouterr().out
@pytest.mark.integration
async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None:
# `run_detection` ouvre sa propre session et commite : `session.rollback()` seul ne défait
# rien ici (contrairement au reste de la suite), d'où le nettoyage explicite ci-dessous, sur
# le modèle de `tests/api/test_matrice_acces.py`.
site = await creer_site(session, capacity_kw=100.0)
site_id = site.site_id
instant = datetime(2026, 9, 16, 12, tzinfo=UTC)
await creer_lecture(session, site_id=site_id, timestamp=instant, consumption_kw=150.0)
await session.commit()
try:
nombre = await internal_alerts.run_detection(now=instant, site_id=site_id)
alertes = await AlertRepository(session).list_all(site_id=site_id)
types = [a.type for a in alertes]
await session.rollback()
assert nombre == 1
assert types == ["threshold"]
finally:
# `site.site_id` n'est plus sûr après `session.rollback()` : le rollback expire tous les
# objets de la session (indépendamment d'`expire_on_commit`), et y accéder ici relance une
# requête hors contexte async. D'où `site_id`, capturé avant.
async with get_session_factory()() as nettoyage:
await nettoyage.execute(
text("delete from alert where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.execute(
text("delete from reading where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.execute(
text("delete from site where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.commit()
+2 -2
View File
@@ -326,6 +326,7 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pandas" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic", extra = ["email"] },
@@ -338,7 +339,6 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pandas-stubs" },
{ name = "pytest" },
@@ -355,6 +355,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pandas", specifier = ">=3.0.5" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
@@ -367,7 +368,6 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=2.3.1" },
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
{ name = "pytest", specifier = ">=9.1.1" },
+5
View File
@@ -34,6 +34,11 @@
},
"configurations": {
"production": {
"optimization": {
"styles": {
"inlineCritical": false
}
},
"budgets": [
{
"type": "initial",
+18
View File
@@ -0,0 +1,18 @@
sonar.projectKey=ProjetPiscine_EnerVision
sonar.organization=groupe3-ener-vision
sonar.sourceEncoding=UTF-8
# Dossier contenant le code source
sonar.sources=apps/frontend/src,apps/backend/app
sonar.tests=apps/backend/tests
# Liste des fichiers et dossiers à exclure de l'analyse
# Liste des fichiers et dossiers à exclure de l'analyse
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
# Chemin vers le rapport de couverture de code
# Fichier généré par Vitest
# Chemin vers le rapport de couverture de code
# Fichier généré par Vitest
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
sonar.python.coverage.reportPaths=apps/backend/cov.info
+8 -3
View File
@@ -21,8 +21,13 @@ export const routes: Routes = [
path: 'sites/:siteId',
canActivate: [authGuard],
loadComponent: () =>
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then(
(m) => m.SiteDetailPlaceholder,
),
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
},
{
path: 'monitoring/sensors',
canActivate: [authGuard],
data: { role: 'admin' },
loadComponent: () =>
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
},
];
@@ -64,4 +64,13 @@ describe('mockApiInterceptor', () => {
httpMock.expectNone(`${environment.apiUrl}/alerts`);
expect((result as unknown[]).length).toBeGreaterThan(0);
});
it('laisse toujours passer /predictions vers le réseau, même avec useMockFixtures activé', () => {
environment.useMockFixtures = true;
http.get(`${environment.apiUrl}/predictions`).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/predictions`);
req.flush({ timestamp: '2026-09-18T09:00:00Z', sites: [] });
});
});
@@ -26,5 +26,7 @@ export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
if (req.url.endsWith(`${environment.apiUrl}/alerts`)) {
return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE }));
}
// Volontairement jamais mocké, contrairement à `stats`/`alerts` : les prévisions sont servies
// par l'API réelle dès maintenant (au même titre que `/auth/*`, déjà toujours réel).
return next(req);
};
@@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { PredictionsService } from './predictions.service';
import { environment } from '../../../environments/environment';
describe('PredictionsService', () => {
let service: PredictionsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(PredictionsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('appelle le bon endpoint et retourne un résumé de prévisions', () => {
let result: unknown;
service.getPredictions().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/predictions`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-18T09:00:00Z',
sites: [{ site_id: 'SITE001', site_name: 'Test', prediction: null }],
});
expect((result as { sites: unknown[] }).sites.length).toBe(1);
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { PredictionSummary } from '../../shared/models/prediction.model';
@Service()
export class PredictionsService {
private http = inject(HttpClient);
getPredictions() {
return this.http.get<PredictionSummary>(`${environment.apiUrl}/predictions`);
}
}
@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ReadingsService } from './readings.service';
import { environment } from '../../../environments/environment';
describe('ReadingsService', () => {
let service: ReadingsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(ReadingsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it("demande l'historique du site avec la fenêtre temporelle donnée", () => {
let result: unknown;
service
.getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z')
.subscribe((r) => (result = r));
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z');
expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z');
req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]);
expect((result as unknown[]).length).toBe(1);
});
it('ne pose pas de paramètres start/end quand ils sont omis', () => {
service.getHistory('SITE001').subscribe();
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
);
expect(req.request.params.has('start')).toBe(false);
expect(req.request.params.has('end')).toBe(false);
req.flush([]);
});
});
@@ -0,0 +1,20 @@
import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Reading } from '../../shared/models/reading.model';
@Service()
export class ReadingsService {
private http = inject(HttpClient);
getHistory(siteId: string, start?: string, end?: string) {
let params = new HttpParams().set('site_id', siteId);
if (start) {
params = params.set('start', start);
}
if (end) {
params = params.set('end', end);
}
return this.http.get<Reading[]>(`${environment.apiUrl}/readings`, { params });
}
}
@@ -0,0 +1,48 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { SensorsService } from './sensors.service';
import { environment } from '../../../environments/environment';
describe('SensorsService', () => {
let service: SensorsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(SensorsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it("appelle l'endpoint /sensors/status et retourne la réponse", () => {
let result: unknown;
service.getStatus().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-18T08:00:00',
sites: [
{
site_id: 'SITE001',
site_name: 'Test',
overall: 'ok',
sensors: {
consumption: { status: 'ok', since: null },
electrical: { status: 'ok', since: null },
temperature: { status: 'ok', since: null },
humidity: { status: 'ok', since: null },
network: { status: 'ok', since: null },
},
},
],
});
expect((result as { sites: unknown[] }).sites.length).toBe(1);
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import {SensorStatusResponse} from '../../shared/models/sensor-status.model';
@Service()
export class SensorsService {
private http = inject(HttpClient);
getStatus() {
return this.http.get<SensorStatusResponse>(`${environment.apiUrl}/sensors/status`);
}
}
@@ -38,4 +38,50 @@ describe('SitesService', () => {
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
});
it('appelle le bon endpoint et retourne un site', () => {
let result: unknown;
service.getSite('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`);
expect(req.request.method).toBe('GET');
req.flush({
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
});
expect((result as { site_id: string }).site_id).toBe('SITE001');
});
it('appelle le bon endpoint et retourne la mesure courante du site', () => {
let result: unknown;
service.getCurrent('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-17T10:00:00Z',
site_id: 'SITE001',
site_type: 'industriel',
consumption_kw: 120,
consumption_kwh: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: 22,
humidity_percent: 55,
null_reasons: ['electrical_sensor_failure'],
data_quality: 'partial',
});
expect((result as { null_reasons: string[] }).null_reasons).toEqual([
'electrical_sensor_failure',
]);
});
});
@@ -2,6 +2,7 @@ import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Site } from '../../shared/models/site.model';
import { SiteCurrent } from '../../shared/models/site-current.model';
@Service()
export class SitesService {
@@ -10,4 +11,12 @@ export class SitesService {
getSites() {
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
}
getSite(siteId: string) {
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
}
getCurrent(siteId: string) {
return this.http.get<SiteCurrent>(`${environment.apiUrl}/sites/${siteId}/current`);
}
}
@@ -10,6 +10,9 @@
</div>
</div>
<div class="dashboard__actions">
@if (auth.principal()?.role === 'admin') {
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
}
<a routerLink="/sites" class="ev-link">Voir les sites</a>
<ev-button
class="logout-button"
@@ -21,7 +24,13 @@
</div>
</header>
@if (error(); as message) {
@if (statsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (alertsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (predictionsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@@ -72,4 +81,33 @@
</ul>
</section>
}
@if (predictions().length > 0) {
<section class="predictions-section">
<h2>Prévisions de consommation</h2>
<ul class="predictions-list">
@for (site of predictions(); track site.site_id) {
<li class="prediction-item">
<span class="prediction-item__site">{{ site.site_name }}</span>
@if (site.prediction; as prediction) {
@if (prediction.status === 'available') {
<span class="prediction-item__value">
{{ prediction.predicted_value | number: '1.0-1' }} kWh
<span class="prediction-item__target"
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
>
</span>
} @else {
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
}}</ev-badge>
}
} @else {
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
}
</li>
}
</ul>
</section>
}
</div>
@@ -68,6 +68,15 @@ h2 {
text-align: center;
}
.card--link {
cursor: pointer;
transition: border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted);
@@ -121,3 +130,40 @@ h2 {
.alert-item__message {
font-size: 0.9rem;
}
.predictions-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prediction-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
}
.prediction-item__site {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__value {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__target {
margin-left: 0.35rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--color-text-muted);
}
@@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs';
import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
import { PredictionsService } from '../../core/services/predictions.service';
import {AuthService} from '../../core/services/auth.service';
import {Router, provideRouter} from '@angular/router';
@@ -17,18 +18,24 @@ vi.mock('chart.js', () => {
return { Chart: ChartMock, registerables: [] };
});
function predictionsMock(sites: unknown[] = []) {
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
}
describe('Dashboard', () => {
afterEach(() => vi.useRealTimers());
it('charge les stats et les alertes au démarrage', async () => {
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
@@ -42,8 +49,12 @@ describe('Dashboard', () => {
expect(statsMock.getSummary).toHaveBeenCalled();
expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(predictions.getPredictions).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
expect(fixture.componentInstance.predictions().length).toBe(1);
expect(fixture.componentInstance.statsError()).toBeNull();
expect(fixture.componentInstance.alertsError()).toBeNull();
expect(fixture.componentInstance.predictionsError()).toBeNull();
});
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
@@ -61,6 +72,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
@@ -70,13 +82,13 @@ describe('Dashboard', () => {
vi.advanceTimersByTime(1);
expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.statsError()).not.toBeNull();
expect(fixture.componentInstance.stats()).toBeNull();
vi.advanceTimersByTime(10000);
expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.stats()).not.toBeNull();
expect(fixture.componentInstance.error()).toBeNull();
expect(fixture.componentInstance.statsError()).toBeNull();
});
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
@@ -88,6 +100,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
@@ -96,18 +109,78 @@ describe('Dashboard', () => {
fixture.detectChanges();
expect(fixture.componentInstance.alerts().length).toBe(0);
expect(fixture.componentInstance.alertsError()).not.toBeNull();
});
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0);
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
});
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
vi.useFakeTimers();
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
// toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée.
vi.advanceTimersByTime(30000);
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
expect(fixture.componentInstance.statsError()).toBeNull();
});
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
const authMock = {
logout: vi.fn().mockReturnValue(of(undefined)),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
@@ -128,15 +201,17 @@ describe('Dashboard', () => {
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = {
const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
@@ -164,6 +239,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
@@ -179,4 +255,26 @@ describe('Dashboard', () => {
dashboard.badgeToneForSeverity('critical'),
);
});
it('distingue le ton des statuts de prévision', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning');
expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger');
});
});
@@ -1,15 +1,17 @@
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe } from '@angular/common';
import { DecimalPipe, DatePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertsService } from '../../core/services/alerts.service';
import { PredictionsService } from '../../core/services/predictions.service';
import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge';
@@ -27,11 +29,21 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
critical: 'critical',
};
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
// tomber sur `undefined` si ce statut apparaît un jour.
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
available: 'success',
insufficient_data: 'warning',
error: 'danger',
};
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
DecimalPipe,
DatePipe,
RouterLink,
ConsumptionGauge,
SiteLoadChart,
@@ -47,31 +59,52 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
private auth = inject(AuthService);
public auth = inject(AuthService);
private predictionsService = inject(PredictionsService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null);
alerts = signal<Alert[]>([]);
error = signal<string | null>(null);
predictions = signal<SitePredictionSummary[]>([]);
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
statsError = signal<string | null>(null);
alertsError = signal<string | null>(null);
predictionsError = signal<string | null>(null);
ngOnInit(): void {
this.alertsService
.getAlerts()
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((alerts) => this.alerts.set(alerts));
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
.subscribe((alerts) => {
this.alertsError.set(null);
this.alerts.set(alerts);
});
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService
.getPredictions()
.pipe(catchError(() => this.reportUnavailable(this.predictionsError)))
.subscribe((summary) => {
this.predictionsError.set(null);
this.predictions.set(summary.sites);
});
// Piège : le catchError porte sur l'observable interne. Sur le flux externe il
// terminerait le timer, et le rafraîchissement ne repartirait jamais.
timer(0, REFRESH_INTERVAL_MS)
.pipe(
switchMap(() =>
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())),
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((stats) => {
this.error.set(null);
this.statsError.set(null);
this.stats.set(stats);
});
}
@@ -80,6 +113,10 @@ export class Dashboard implements OnInit {
return TON_PAR_SEVERITE[severity];
}
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
return TON_PAR_STATUT_PREDICTION[status];
}
onLogout(): void {
this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']),
@@ -91,8 +128,8 @@ export class Dashboard implements OnInit {
});
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
private reportUnavailable(target: WritableSignal<string | null>): Observable<never> {
target.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -0,0 +1,51 @@
<div class="sensor-status">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="sensor-status__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="sensor-status__logo" />
</a>
<div>
<h1>Supervision des capteurs</h1>
<p class="sensor-status__subtitle">État de santé par capteur et par site</p>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (data(); as d) {
<div class="sites-grid">
@for (site of d.sites; track site.site_id) {
<ev-card class="site-card">
<div class="site-card__header">
<span class="site-card__name">{{ site.site_name }}</span>
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
</div>
<ul class="sensor-list">
@for (entry of sensorEntries; track entry[0]) {
@let diagnostic = sensorOf(site.sensors, entry[0]);
<li class="sensor-item">
<span class="sensor-dot" [class]="'sensor-dot--' + diagnostic.status"></span>
<span class="sensor-item__label">{{ entry[1] }}</span>
@if (diagnostic.status === 'failing') {
<span class="sensor-item__since">
@if (diagnostic.since; as since) {
dernière lecture le {{ since | date: 'dd/MM/yyyy HH:mm' }}
} @else {
aucune lecture reçue
}
</span>
}
</li>
}
</ul>
</ev-card>
}
</div>
}
</div>
@@ -0,0 +1,90 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.sensor-status__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.sensor-status__logo {
font-size: 1.3rem;
}
.sensor-status__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
}
.sites-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.site-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.site-card__name {
font-weight: 600;
}
.sensor-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sensor-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.sensor-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--ok {
background: var(--color-success);
}
&--failing {
background: var(--color-danger);
}
}
.sensor-item__label {
flex: 1;
}
.sensor-item__since {
color: var(--color-text-muted);
font-size: 0.75rem;
}
@@ -0,0 +1,122 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { SensorStatusView } from './sensor-status';
import { SensorsService } from '../../../core/services/sensors.service';
import { SiteSensors } from '../../../shared/models/sensor-status.model';
import {provideRouter} from '@angular/router';
const OK_SENSORS: SiteSensors = {
consumption: { status: 'ok', since: null },
electrical: { status: 'ok', since: null },
temperature: { status: 'ok', since: null },
humidity: { status: 'ok', since: null },
network: { status: 'ok', since: null },
};
describe('SensorStatusView', () => {
let sensorsMock: { getStatus: ReturnType<typeof vi.fn> };
beforeEach(() => {
sensorsMock = { getStatus: vi.fn() };
TestBed.configureTestingModule({
imports: [SensorStatusView],
providers: [
{ provide: SensorsService, useValue: sensorsMock },
provideRouter([]),
],
});
});
it('charge et affiche les données au démarrage', () => {
sensorsMock.getStatus.mockReturnValue(
of({
timestamp: '2026-09-18T08:00:00',
sites: [
{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS },
],
})
);
const fixture = TestBed.createComponent(SensorStatusView);
fixture.detectChanges();
expect(fixture.componentInstance.data()?.sites.length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Bureau Test');
});
it("affiche un message d'erreur si l'appel échoue", () => {
sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom')));
const fixture = TestBed.createComponent(SensorStatusView);
fixture.detectChanges();
expect(fixture.componentInstance.error()).toBe(
'État des capteurs indisponible, réessayez plus tard.'
);
expect(fixture.componentInstance.data()).toBeNull();
expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible');
});
it('associe le bon ton de badge à chaque statut global', () => {
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
const fixture = TestBed.createComponent(SensorStatusView);
const component = fixture.componentInstance;
expect(component.badgeToneForOverall('ok')).toBe('success');
expect(component.badgeToneForOverall('degraded')).toBe('warning');
expect(component.badgeToneForOverall('critical')).toBe('critical');
expect(component.badgeToneForOverall('inconnu')).toBe('neutral');
});
it('retourne le bon diagnostic via sensorOf', () => {
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
const fixture = TestBed.createComponent(SensorStatusView);
const component = fixture.componentInstance;
expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null });
});
it('affiche la date de la dernière lecture reçue pour un capteur en panne', () => {
const sensors: SiteSensors = {
...OK_SENSORS,
temperature: { status: 'failing', since: '2026-09-18T08:00:00' },
};
sensorsMock.getStatus.mockReturnValue(
of({
timestamp: '2026-09-18T08:00:00',
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }],
})
);
const fixture = TestBed.createComponent(SensorStatusView);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('dernière lecture le');
expect(fixture.nativeElement.textContent).toContain('18/09/2026 08:00');
});
it("annonce l'absence de lecture quand un site n'en a jamais reçu", () => {
const sensors: SiteSensors = {
consumption: { status: 'failing', since: null },
electrical: { status: 'failing', since: null },
temperature: { status: 'failing', since: null },
humidity: { status: 'failing', since: null },
network: { status: 'failing', since: null },
};
sensorsMock.getStatus.mockReturnValue(
of({
timestamp: '2026-09-18T08:00:00',
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'critical', sensors }],
})
);
const fixture = TestBed.createComponent(SensorStatusView);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('aucune lecture reçue');
expect(fixture.nativeElement.textContent).not.toContain('dernière lecture le');
});
});
@@ -0,0 +1,65 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, EMPTY, Observable } from 'rxjs';
import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge';
import {Card} from '../../../shared/components/ui/card/card';
import {Alert} from '../../../shared/components/ui/alert/alert';
import {Brand} from '../../../shared/components/ui/brand/brand';
import {SensorsService} from '../../../core/services/sensors.service';
import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model';
import { DatePipe } from '@angular/common';
const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.';
const SENSOR_LABELS: Record<string, string> = {
consumption: 'Consommation',
electrical: 'Électrique',
temperature: 'Température',
humidity: 'Humidité',
network: 'Réseau',
};
const TON_PAR_OVERALL: Record<string, BadgeTone> = {
ok: 'success',
degraded: 'warning',
critical: 'critical',
};
@Component({
selector: 'app-sensor-status',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe],
templateUrl: './sensor-status.html',
styleUrl: './sensor-status.scss',
})
export class SensorStatusView implements OnInit {
private sensorsService = inject(SensorsService);
data = signal<SensorStatusResponse | null>(null);
error = signal<string | null>(null);
readonly sensorEntries = Object.entries(SENSOR_LABELS);
ngOnInit(): void {
this.sensorsService
.getStatus()
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((response) => {
this.error.set(null);
this.data.set(response);
});
}
sensorOf(sensors: Record<string, SensorDiagnostic>, key: string): SensorDiagnostic {
return sensors[key];
}
badgeToneForOverall(overall: string): BadgeTone {
return TON_PAR_OVERALL[overall] ?? 'neutral';
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -1,19 +0,0 @@
<div class="site-detail-placeholder">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
<span>/</span>
<a routerLink="/sites">Sites</a>
</nav>
<header class="site-detail-placeholder__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-detail-placeholder__logo" />
</a>
<h1>Site {{ siteId() }}</h1>
</header>
<ev-card>
<p>Le détail de ce site est à venir (voir issue #51).</p>
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</ev-card>
</div>
@@ -1,28 +0,0 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 640px;
margin: 0 auto;
}
.site-detail-placeholder__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 1.5rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
}
.site-detail-placeholder__logo {
font-size: 1.3rem;
}
ev-card p {
margin: 0 0 0.75rem;
}
@@ -1,42 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { SiteDetailPlaceholder } from './site-detail-placeholder';
describe('SiteDetailPlaceholder', () => {
it("affiche l'identifiant du site depuis la route", () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE001');
});
it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE002');
expect(fixture.nativeElement.textContent).not.toContain('SITE001');
});
});
@@ -1,19 +0,0 @@
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { Card } from '../../../shared/components/ui/card/card';
import { Brand } from '../../../shared/components/ui/brand/brand';
@Component({
selector: 'app-site-detail-placeholder',
standalone: true,
imports: [RouterLink, Card, Brand],
templateUrl: './site-detail-placeholder.html',
styleUrl: './site-detail-placeholder.scss',
})
export class SiteDetailPlaceholder {
private route = inject(ActivatedRoute);
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId'))));
}
@@ -0,0 +1,85 @@
<div class="site-detail">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
<span>/</span>
<a routerLink="/sites">Sites</a>
</nav>
<header class="site-detail__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-detail__logo" />
</a>
<div>
<h1>{{ site()?.site_name ?? siteId() }}</h1>
@if (site(); as s) {
<p class="site-detail__subtitle">
{{ s.site_type }} · {{ s.location || 'Localisation inconnue' }}
</p>
}
</div>
<div class="site-detail__badges">
@if (site(); as s) {
<ev-badge [tone]="badgeToneForStatus(s.status)">{{ s.status ?? '-' }}</ev-badge>
}
@if (hasMeasurement() && qualityLabel(); as label) {
<ev-badge [tone]="qualityTone()">{{ label }}</ev-badge>
}
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (site(); as s) {
@if (hasMeasurement()) {
<section class="overview">
<ev-card class="card card--gauge">
<span class="card__label">Consommation vs capacité</span>
@let consumption = consumptionKw();
@if (consumption !== null) {
<app-consumption-gauge [consumption]="consumption" [capacity]="s.capacity_kw ?? 0" />
<span class="card__value">
{{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW
</span>
} @else {
<p class="card__unavailable">
Consommation indisponible
<span class="metric__reason">({{ consumptionReason() }})</span>
</p>
}
</ev-card>
<ev-card class="metrics-card">
<span class="card__label">Mesure instantanée</span>
<dl class="metrics-grid">
@for (metric of metrics(); track metric.key) {
<div class="metric">
<dt>{{ metric.label }}</dt>
@if (metric.value !== null) {
<dd>{{ metric.value }}</dd>
} @else {
<dd class="metric__unavailable">
Indisponible
<span class="metric__reason">({{ metric.reason }})</span>
</dd>
}
</div>
}
</dl>
</ev-card>
</section>
@if (history().length > 0) {
<section class="chart-section">
<h2>Historique de consommation</h2>
<app-reading-history-chart [readings]="history()" />
</section>
}
} @else {
<ev-alert severity="warning" class="banner-empty">{{ noMeasurementMessage }}</ev-alert>
}
}
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</div>
@@ -0,0 +1,119 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.site-detail__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.site-detail__logo {
font-size: 1.3rem;
}
.site-detail__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.site-detail__badges {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
}
.banner-empty {
display: block;
margin: 0 0 1.5rem;
}
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
}
.card {
padding: 1.25rem;
gap: 0.35rem;
}
.card--gauge {
align-items: center;
text-align: center;
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.card__value {
font-size: 1.6rem;
font-weight: 700;
}
.card__unavailable {
color: var(--color-text-muted);
margin: 0;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem 1.5rem;
margin: 0.5rem 0 0;
}
.metric {
dt {
font-size: 0.75rem;
color: var(--color-text-muted);
}
dd {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
}
.metric__unavailable {
color: var(--color-text-muted);
font-weight: 400;
}
.metric__reason {
font-size: 0.8rem;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 1rem;
}
.chart-section {
margin-bottom: 2rem;
}
@@ -0,0 +1,281 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { SiteDetail } from './site-detail';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
const SITE = {
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
};
const CURRENT_COMPLET = {
timestamp: '2026-09-17T10:00:00Z',
site_id: 'SITE001',
site_type: 'industriel',
consumption_kw: 120,
consumption_kwh: null,
voltage_v: 230,
current_a: 12,
power_factor: 0.95,
temperature_celsius: 22,
humidity_percent: 55,
null_reasons: [] as string[],
data_quality: 'good' as const,
};
const SANS_MESURE = {
...CURRENT_COMPLET,
timestamp: null,
consumption_kw: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: null,
humidity_percent: null,
data_quality: 'critical' as const,
};
const LECTURE = {
reading_id: 1,
site_id: 'SITE001',
timestamp: '2026-09-17T09:00:00Z',
source: 'api_history' as const,
consumption_kw: 118,
consumption_kwh: null,
consumption_euros: null,
voltage_v: 230,
current_a: 12,
power_factor: 0.95,
temperature_celsius: 22,
humidity_percent: 55,
solar_irradiance_wm2: null,
is_working_hours: true,
data_quality: 'good' as const,
null_reasons: null,
imputed_values: null,
imputation_method: null,
};
function setup(
siteId: string,
sitesMock: Partial<SitesService>,
readingsMock: Partial<ReadingsService>,
) {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
TestBed.configureTestingModule({
imports: [SiteDetail],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
{ provide: SitesService, useValue: sitesMock },
{ provide: ReadingsService, useValue: readingsMock },
],
});
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
}
describe('SiteDetail', () => {
it('charge le site, la mesure courante et son historique au démarrage', () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
expect(fixture.componentInstance.current()?.consumption_kw).toBe(120);
expect(fixture.componentInstance.history().length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
});
it("signale l'indisponibilité quand un des appels échoue", () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.site()).toBeNull();
});
it('efface les données du site précédent quand le chargement du suivant échoue', () => {
const getSite = vi
.fn()
.mockReturnValueOnce(of(SITE))
.mockReturnValueOnce(throwError(() => new Error('404')));
const { fixture, paramMap } = setup(
'SITE001',
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.site()).toBeNull();
expect(fixture.componentInstance.current()).toBeNull();
expect(fixture.componentInstance.history()).toEqual([]);
expect(fixture.nativeElement.textContent).not.toContain('Site 1');
});
it('interroge le site et sa mesure courante en parallèle', () => {
const getSite = vi.fn().mockReturnValue(of(SITE));
const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET));
const { fixture } = setup(
'SITE001',
{ getSite, getCurrent },
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
expect(getSite).toHaveBeenCalledWith('SITE001');
expect(getCurrent).toHaveBeenCalledWith('SITE001');
});
it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => {
const sansConsommation = {
...CURRENT_COMPLET,
consumption_kw: null,
null_reasons: ['consumption_sensor_failure'],
data_quality: 'partial' as const,
};
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(sansConsommation)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.consumptionKw()).toBeNull();
expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne');
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Consommation indisponible');
});
it('trace la jauge pour une consommation nulle réellement mesurée', () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW');
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible');
});
it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => {
const partielle = {
...CURRENT_COMPLET,
voltage_v: null,
current_a: null,
power_factor: null,
null_reasons: ['electrical_sensor_failure'],
data_quality: 'partial' as const,
};
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(partielle)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v');
expect(tension?.value).toBeNull();
expect(tension?.reason).toBe('capteur électrique en panne');
const texte = fixture.nativeElement.textContent;
expect(texte).toContain('Indisponible');
expect(texte).toContain('capteur électrique en panne');
expect(texte).toContain('Données partielles');
});
it('recharge les données quand le paramètre de route siteId change', () => {
const getSite = vi.fn().mockReturnValue(of(SITE));
const { fixture, paramMap } = setup(
'SITE001',
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(getSite).toHaveBeenCalledWith('SITE002');
});
it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory },
);
fixture.detectChanges();
expect(getHistory).toHaveBeenCalledWith(
'SITE001',
'2026-09-16T10:00:00.000Z',
'2026-09-17T10:00:00Z',
);
});
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)),
},
{ getHistory },
);
fixture.detectChanges();
expect(getHistory).not.toHaveBeenCalled();
expect(fixture.componentInstance.hasMeasurement()).toBe(false);
expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.');
});
});
@@ -0,0 +1,213 @@
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
import { Site } from '../../../shared/models/site.model';
import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model';
import { SiteCurrent } from '../../../shared/models/site-current.model';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000;
const TON_PAR_STATUT: Record<string, BadgeTone> = {
actif: 'success',
maintenance: 'warning',
hors_service: 'danger',
};
const TON_PAR_QUALITE: Record<ReadingDataQuality, BadgeTone> = {
good: 'success',
partial: 'warning',
degraded: 'danger',
critical: 'critical',
};
const LIBELLE_PAR_QUALITE: Record<ReadingDataQuality, string> = {
good: 'Données complètes',
partial: 'Données partielles',
degraded: 'Données dégradées',
critical: 'Données critiques',
};
type MetricKey =
| 'consumption_kw'
| 'voltage_v'
| 'current_a'
| 'power_factor'
| 'temperature_celsius'
| 'humidity_percent';
interface MetricDef {
key: MetricKey;
label: string;
format: (value: number) => string;
}
const CONSUMPTION_DEF: MetricDef = {
key: 'consumption_kw',
label: 'Consommation',
format: (v) => `${v.toFixed(1)} kW`,
};
const METRIC_DEFS: MetricDef[] = [
CONSUMPTION_DEF,
{ key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` },
{ key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` },
{ key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) },
{ key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` },
{ key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` },
];
// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ;
// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide.
const RAISONS_PAR_CHAMP: Record<MetricKey, string[]> = {
consumption_kw: ['consumption_sensor_failure', 'network_loss'],
voltage_v: ['electrical_sensor_failure', 'network_loss'],
current_a: ['electrical_sensor_failure', 'network_loss'],
power_factor: ['electrical_sensor_failure', 'network_loss'],
temperature_celsius: ['temperature_sensor_failure', 'network_loss'],
humidity_percent: ['humidity_sensor_failure', 'network_loss'],
};
const LIBELLE_PAR_RAISON: Record<string, string> = {
consumption_sensor_failure: 'capteur de consommation en panne',
electrical_sensor_failure: 'capteur électrique en panne',
temperature_sensor_failure: 'capteur de température en panne',
humidity_sensor_failure: "capteur d'humidité en panne",
network_loss: 'perte réseau',
};
export interface MetricView {
key: MetricKey;
label: string;
value: string | null;
reason: string;
}
@Component({
selector: 'app-site-detail',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
templateUrl: './site-detail.html',
styleUrl: './site-detail.scss',
})
export class SiteDetail {
private route = inject(ActivatedRoute);
private sitesService = inject(SitesService);
private readingsService = inject(ReadingsService);
private destroyRef = inject(DestroyRef);
readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE;
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? '')));
site = signal<Site | null>(null);
current = signal<SiteCurrent | null>(null);
history = signal<Reading[]>([]);
error = signal<string | null>(null);
hasMeasurement = computed(() => this.current()?.timestamp != null);
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
consumptionLabel = computed(() => {
const kw = this.consumptionKw();
return kw != null ? CONSUMPTION_DEF.format(kw) : null;
});
consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current()));
qualityLabel = computed(() => {
const quality = this.current()?.data_quality;
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
});
qualityTone = computed<BadgeTone>(() => {
const quality = this.current()?.data_quality;
return quality ? TON_PAR_QUALITE[quality] : 'neutral';
});
metrics = computed<MetricView[]>(() => {
const current = this.current();
return METRIC_DEFS.map((def) => {
const valeur = current ? current[def.key] : null;
return {
key: def.key,
label: def.label,
value: valeur != null ? def.format(valeur) : null,
reason: valeur == null ? this.reasonFor(def.key, current) : '',
};
});
});
constructor() {
toObservable(this.siteId)
.pipe(
filter((siteId): siteId is string => !!siteId),
// Piège : switchMap sur le flux externe annule le chargement en cours dès qu'un
// nouveau siteId arrive, sinon une réponse en retard peut écraser le site affiché.
switchMap((siteId) => this.load(siteId)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((result) => {
this.error.set(null);
this.site.set(result.site);
this.current.set(result.current);
this.history.set(result.history);
});
}
badgeToneForStatus(status: string | null): BadgeTone {
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
}
private load(siteId: string) {
return forkJoin({
site: this.sitesService.getSite(siteId),
current: this.sitesService.getCurrent(siteId),
}).pipe(
switchMap(({ site, current }) =>
this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))),
),
catchError(() => this.reportUnavailable()),
);
}
private loadHistory(siteId: string, current: SiteCurrent): Observable<Reading[]> {
// Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la
// dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide.
const end = current.timestamp;
if (end === null) {
return of([]);
}
const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString();
return this.readingsService.getHistory(siteId, start, end);
}
private reasonFor(field: MetricKey, current: SiteCurrent | null): string {
const raisons = RAISONS_PAR_CHAMP[field];
const trouvees = (current?.null_reasons ?? [])
.filter((raison) => raisons.includes(raison))
.map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison);
return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue';
}
// Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé
// sous le bandeau et laisse lire les chiffres de A en croyant regarder B.
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
this.site.set(null);
this.current.set(null);
this.history.set([]);
return EMPTY;
}
}
@@ -0,0 +1 @@
<canvas #canvas></canvas>
@@ -0,0 +1,4 @@
:host {
display: block;
height: 260px;
}
@@ -0,0 +1,101 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { Chart } from 'chart.js';
import { ReadingHistoryChart } from './reading-history-chart';
vi.mock('chart.js', () => {
class ChartMock {
static instances: ChartMock[] = [];
static register = vi.fn();
update = vi.fn();
destroy = vi.fn();
data: { labels?: unknown[]; datasets: Record<string, unknown>[] } = { datasets: [{}] };
constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) {
if (config?.data) {
this.data = config.data;
}
ChartMock.instances.push(this);
}
}
return { Chart: ChartMock, registerables: [] };
});
type ChartDouble = {
destroy: ReturnType<typeof vi.fn>;
data: { labels?: unknown[]; datasets: Record<string, unknown>[] };
};
function lastChart(): ChartDouble | undefined {
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
}
const READING = {
reading_id: 1,
site_id: 'S1',
timestamp: '2026-09-17T10:00:00Z',
source: 'api_history' as const,
consumption_kw: 42,
consumption_kwh: null,
consumption_euros: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: null,
humidity_percent: null,
solar_irradiance_wm2: null,
is_working_hours: null,
data_quality: 'good' as const,
null_reasons: null,
imputed_values: null,
imputation_method: null,
};
describe('ReadingHistoryChart', () => {
it('se crée sans erreur avec une liste de lectures valide', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
expect(() => fixture.detectChanges()).not.toThrow();
});
it('met à jour le graphique quand les lectures changent après initialisation', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
fixture.detectChanges();
fixture.componentRef.setInput('readings', [
{ ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const },
]);
fixture.detectChanges();
expect(() => fixture.detectChanges()).not.toThrow();
});
it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
// L'API trie en timestamp décroissant : le composant doit rétablir la chronologie.
fixture.componentRef.setInput('readings', [
{ ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 },
{ ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 },
]);
fixture.detectChanges();
const chart = lastChart();
expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']);
expect(chart?.data.datasets[0]['data']).toEqual([42, 60]);
});
it('détruit le graphique quand le composant est détruit', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
fixture.detectChanges();
const chart = lastChart();
fixture.destroy();
expect(chart?.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,95 @@
import {
Component,
ElementRef,
ViewChild,
input,
effect,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { Chart, registerables } from 'chart.js';
import { Reading, ReadingDataQuality } from '../../models/reading.model';
Chart.register(...registerables);
const QUALITY_COLORS: Record<ReadingDataQuality, string> = {
good: '#3b82f6',
partial: '#f9a825',
degraded: '#ef6c00',
critical: '#c62828',
};
const UNKNOWN_QUALITY_COLOR = '#9ca3af';
interface ChartSeries {
labels: string[];
values: number[];
colors: string[];
}
// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history
// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours.
function toSeries(readings: Reading[]): ChartSeries {
const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
return {
labels: ordered.map((r) => r.timestamp),
values: ordered.map((r) => r.consumption_kw ?? 0),
colors: ordered.map((r) =>
r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR,
),
};
}
@Component({
selector: 'app-reading-history-chart',
standalone: true,
templateUrl: './reading-history-chart.html',
styleUrl: './reading-history-chart.scss',
})
export class ReadingHistoryChart implements AfterViewInit, OnDestroy {
readings = input.required<Reading[]>();
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
private chart?: Chart<'line'>;
constructor() {
effect(() => {
const series = toSeries(this.readings());
if (this.chart) {
this.chart.data.labels = series.labels;
this.chart.data.datasets[0].data = series.values;
this.chart.data.datasets[0].pointBackgroundColor = series.colors;
this.chart.update('none');
}
});
}
ngAfterViewInit(): void {
const series = toSeries(this.readings());
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'line',
data: {
labels: series.labels,
datasets: [
{
data: series.values,
borderColor: '#3b82f6',
pointBackgroundColor: series.colors,
tension: 0.25,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } },
},
},
});
}
ngOnDestroy(): void {
this.chart?.destroy();
}
}
@@ -0,0 +1,24 @@
export type PredictionStatus = 'available' | 'insufficient_data' | 'error';
export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw';
export interface SitePrediction {
target_at: string;
target_metric: PredictionTargetMetric;
period_minutes: number | null;
predicted_value: number | null;
status: PredictionStatus;
failure_reason: string | null;
model_reference: string;
created_at: string;
}
export interface SitePredictionSummary {
site_id: string;
site_name: string;
prediction: SitePrediction | null;
}
export interface PredictionSummary {
timestamp: string;
sites: SitePredictionSummary[];
}
@@ -0,0 +1,23 @@
export type ReadingSource = 'csv' | 'api_current' | 'api_history';
export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical';
export interface Reading {
reading_id: number;
site_id: string;
timestamp: string;
source: ReadingSource;
consumption_kw: number | null;
consumption_kwh: number | null;
consumption_euros: string | null;
voltage_v: number | null;
current_a: number | null;
power_factor: number | null;
temperature_celsius: number | null;
humidity_percent: number | null;
solar_irradiance_wm2: number | null;
is_working_hours: boolean | null;
data_quality: ReadingDataQuality | null;
null_reasons: string[] | null;
imputed_values: Record<string, unknown> | null;
imputation_method: string | null;
}
@@ -0,0 +1,28 @@
export type SensorStatus = 'ok' | 'failing';
export type OverallStatus = 'ok' | 'degraded' | 'critical';
export interface SensorDiagnostic {
status: SensorStatus;
since: string | null;
}
export interface SiteSensors {
consumption: SensorDiagnostic;
electrical: SensorDiagnostic;
temperature: SensorDiagnostic;
humidity: SensorDiagnostic;
network: SensorDiagnostic;
[key: string]: SensorDiagnostic;
}
export interface SiteSensorStatus {
site_id: string;
site_name: string;
sensors: SiteSensors;
overall: OverallStatus;
}
export interface SensorStatusResponse {
timestamp: string;
sites: SiteSensorStatus[];
}
@@ -0,0 +1,16 @@
import { ReadingDataQuality } from './reading.model';
export interface SiteCurrent {
timestamp: string | null;
site_id: string;
site_type: string;
consumption_kw: number | null;
consumption_kwh: number | null;
voltage_v: number | null;
current_a: number | null;
power_factor: number | null;
temperature_celsius: number | null;
humidity_percent: number | null;
null_reasons: string[];
data_quality: ReadingDataQuality;
}
@@ -1,5 +1,5 @@
export const environment = {
production: false,
apiUrl: '/api/v1',
useMockFixtures: true, // a passer a false une fois le backend prêt
useMockFixtures: false,
};
+5
View File
@@ -0,0 +1,5 @@
-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
CREATE DATABASE airflow;
+74
View File
@@ -0,0 +1,74 @@
# Piège : `APP_ENV` et `APP_DEBUG` sont en dur et non en `${APP_ENV:-prod}` : le `.env` du poste
# vaut `local` et reprendrait le dessus, ce qui laisserait le cookie sans `__Secure-` et
# rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide.
# Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
name: enervision
services:
db:
ports: !override
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
mailpit:
ports: !override
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
airflow-webserver:
ports: !override
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
backend:
ports: !reset null
command:
- uvicorn
- app.main:create_app
- --factory
- --host
- 0.0.0.0
- --port
- "8000"
- --proxy-headers
- --forwarded-allow-ips=*
environment:
APP_ENV: prod
APP_DEBUG: "false"
APP_TRUST_PROXY_HEADERS: "true"
APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local}
APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password
frontend:
ports: !reset null
proxy:
image: nginx:1.28-alpine
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
ports:
- "80:80"
- "443:443"
volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
- ./infra/proxy/tls:/etc/nginx/tls:ro
- acme_webroot:/var/www/certbot
restart: unless-stopped
certbot:
image: certbot/certbot:v5.8.0
profiles: ["acme"]
volumes:
- letsencrypt:/etc/letsencrypt
- acme_webroot:/var/www/certbot
- ./infra/proxy/tls:/tls
- ./infra/proxy/acme-deploy-hook.sh:/deploy-hook.sh:ro
volumes:
acme_webroot:
letsencrypt:
+102 -2
View File
@@ -5,6 +5,41 @@
name: enervision
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du
# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce
# dernier puisse au besoin l'inspecter sans en devenir dependant.
x-airflow-common: &airflow-common
build:
context: .
dockerfile: etl/airflow/Dockerfile
environment: &airflow-common-env
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
# filtrer les services : une variable requise manquante casserait aussi `make db-up`,
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent.
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-}
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
# memes identifiants que le backend en attendant.
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
# Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en
# dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`.
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
# Clé distincte de celle de l'API : la détection ne signe ni ne vérifie aucun jeton, et
# Airflow permet d'exécuter du code depuis son interface (cf. ADR 0008).
APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-}
volumes:
- ./etl/airflow/dags:/opt/airflow/dags
- ./etl/airflow/plugins:/opt/airflow/plugins
- airflow_logs:/opt/airflow/logs
- airflow_ml_state:/opt/ml/state
restart: unless-stopped
services:
db:
image: timescale/timescaledb-ha:pg17
@@ -19,6 +54,7 @@ services:
- pgdata:/home/postgres/pgdata/data
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
- ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro
- ./db/init/120-airflow-database.sql:/docker-entrypoint-initdb.d/120-airflow-database.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
@@ -50,6 +86,12 @@ services:
APP_SECRET_KEY: ${APP_SECRET_KEY:?}
APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr}
APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-}
APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-}
APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10}
APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password}
APP_SMTP_HOST: mailpit
APP_SMTP_PORT: "1025"
@@ -62,9 +104,67 @@ services:
frontend:
build: ./apps/frontend
ports:
- "${FRONTEND_PORT:-3000}:80"
- "${FRONTEND_PORT:-3000}:3000"
restart: unless-stopped
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
# `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement,
# jamais par `argv` (ni `ps`, ni `docker compose config`).
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
# refuse en plus les deux cles de chiffrement vides.
airflow-init:
<<: *airflow-common
restart: "no"
environment:
<<: *airflow-common-env
_AIRFLOW_DB_MIGRATE: "true"
_AIRFLOW_WWW_USER_CREATE: "true"
_AIRFLOW_WWW_USER_USERNAME: ${AIRFLOW_ADMIN_USERNAME:-admin}
_AIRFLOW_WWW_USER_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-}
_AIRFLOW_WWW_USER_EMAIL: ${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}
depends_on:
db:
condition: service_healthy
command:
- bash
- -c
- |
set -euo pipefail
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}"
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
exec airflow version
airflow-webserver:
<<: *airflow-common
command: webserver
ports:
- "${AIRFLOW_PORT:-8080}:8080"
depends_on:
db:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
airflow-scheduler:
<<: *airflow-common
command: scheduler
depends_on:
db:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
volumes:
pgdata:
airflow_logs:
airflow_ml_state:
+4
View File
@@ -11,3 +11,7 @@
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête |
| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
| [0005](adr/0005-modele-prediction-lightgbm.md) | LightGBM pour la prédiction de consommation, un modèle global |
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
@@ -0,0 +1,81 @@
# 0006 - Le moteur de règles de recommandation vit dans le backend
- Statut : accepté
- Date : 2026-09-18
## Contexte
L'issue #38 demande un « moteur de règles pour recommandations », portée par le label `ml`. Le
schéma tranche déjà la forme du résultat : `recommendation(alert_id, action, explanation,
rule_reference)`, avec `alert_id` en clé étrangère `NOT NULL` et une contrainte d'unicité
`uq_recommendation_alert_rule` sur `(alert_id, rule_reference)`. Une recommandation est donc
**dérivée d'une alerte**, jamais d'une mesure brute ni d'une prévision.
Deux emplacements se disputaient le code :
1. `ml/enervision_ml/`, sur le patron de `enervision_ml.score` livré par #37 : un script autonome
qui se connecte par `ML_DATABASE_URL`, écrit une table, et que l'API se contente de lire.
L'[ADR 0005](0005-modele-prediction-lightgbm.md) annonce d'ailleurs #38 de ce côté, en écrivant
que le scoring, le moteur de recommandations et les tests de dérive « consommeront le même
module `enervision_ml.features` ».
2. `apps/backend/app/services/`, où `apps/backend/README.md` place les « regles metier ».
## Décision
**Le moteur vit dans `apps/backend/app/services/`**, sous la forme d'un module pur
`recommendation_rules.py` (le catalogue `REGLES`) et d'une méthode `RecommendationService.generate()`
qui l'applique, persiste et valide la transaction.
Trois raisons :
- **Il n'utilise rien du ML.** Le catalogue lit `alert.type`, `alert.severity`, `alert.value` et
`alert.threshold`. Aucun modèle, aucune feature, aucun `enervision_ml.features` : la phrase de
l'ADR 0005 vaut pour le scoring (#37) et les tests de dérive (#44/#45), qui manipulent bien des
features, pas pour des règles sur alertes. Le label `ml` de #38 désigne le lot fonctionnel
« prédiction et recommandation », pas l'emplacement du code.
- **Il lit et écrit deux tables déjà couvertes par des repositories.** `AlertRepository` sait déjà
filtrer par site. Le placer dans `ml/` obligerait à réécrire ces accès en SQL brut, et à
maintenir deux représentations du même domaine.
- **Le déclencheur HTTP n'a de sens que dans l'API.** `POST /recommendations/generate` doit passer
par `require_role(Role.ADMIN)` et par la session injectée : cela suppose d'être dans
l'application FastAPI.
Le moteur reste néanmoins **déclenchable hors HTTP**, par `python -m app.cli
generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score` : rien
n'oblige à exposer un port pour régénérer des recommandations.
## Conséquences
- L'API gagne sa première route d'écriture métier. La checklist de `20-backend.md` s'applique :
entrée dans `ROLE_MINIMUM` de `tests/api/acces.py`, et `openapi.json` régénéré dans le même
commit.
- `RecommendationService` n'est plus en lecture seule : il reçoit le `Transaction` Protocol déjà
utilisé par `AuthService` et `UserService`, et commite lui-même. Les repositories continuent de
ne pas commiter.
- **L'idempotence est déléguée à la base.** `create_missing()` insère en `ON CONFLICT DO NOTHING`
sur `uq_recommendation_alert_rule` plutôt que de relire avant d'écrire, ce qui supprime la
fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle.
Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée
ferait réapparaître ses recommandations à côté des anciennes.
- **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée
par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ;
l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant
que la détection n'a pas tourné, sans que le moteur soit à retoucher.
- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT`
lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes,
et la détection interne peut alimenter `alert` au fil de l'eau.
- Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait
à rouvrir : le moteur redeviendrait consommateur du pipeline ML.
## Alternatives écartées
- **Module et CLI dans `ml/enervision_ml/`** : cohérent avec le label `ml` et avec la lettre de
l'ADR 0005, mais impose du SQL brut là où deux repositories existent, et laisse la génération
hors de portée de l'API. Redeviendrait le bon choix si les règles se mettaient à consommer des
features ou un modèle.
- **Génération à la volée, sans persistance**, calculée à chaque `GET /recommendations` : supprime
le besoin d'écriture, mais rend la table `recommendation` et sa contrainte d'unicité inutiles,
et interdit toute trace de ce qui a été proposé et quand.
- **Table de configuration des règles en base**, plutôt qu'un catalogue en Python : plus souple,
mais déplace la logique métier hors de la revue de code et hors des tests, pour un besoin que
rien n'exprime à ce stade.
@@ -0,0 +1,120 @@
# 0007 - Terminaison TLS par un reverse proxy Nginx, en Docker Compose
- Statut : accepté
- Date : 2026-09-21
## Contexte
Quatre documents désignaient le même trou. `10-infra.md` ouvrait ses questions par « Quel ingress
remplace Traefik, et qui termine le TLS ». `00-vue-ensemble.md` rangeait « TLS, HSTS et CSP » dans
« Absent, et assumé ». `owasp-traceabilite.md` laissait la ligne API8 transport ouverte.
`31-contrat-authentification.md` listait deux corrections « à faire avant la démonstration » :
servir le SPA et l'API sous la même origine, et servir en HTTPS.
Ce n'est pas un durcissement facultatif, c'est une condition de fonctionnement. Les deux fichiers
`apps/frontend/src/environments/environment*.ts` portent `apiUrl: '/api/v1'`, en relatif. En
développement, `proxy.conf.json` route `/api` vers l'API. Une fois en conteneur, plus rien ne le
fait : l'application déployée ne peut pas appeler son API. Et le cookie de rafraîchissement prend
le préfixe `__Secure-` dès que `APP_ENV` sort de `local`, donc sans HTTPS il n'est jamais posé et
l'authentification ne tient pas au rechargement de page.
La contrainte qui cadre tout le reste : **aucun nom de domaine public n'existe**. La cible
documentée est le serveur on-premise de l'école, `ssh_host = "10.0.0.10"` dans le
`terraform.tfvars.example`. Sur une adresse privée, le défi HTTP-01 de Let's Encrypt ne peut pas
aboutir, faute de DNS public et de port 80 entrant.
## Décision
**Un service `proxy` dans Docker Compose**, image officielle `nginx:1.28-alpine`, seul composant à
publier des ports sur la machine : 80 et 443. Backend et frontend ne sont plus publiés du tout, la
base et l'interface Mailpit sont ramenées sur la boucle locale. La stack complète est décrite par
l'overlay `docker-compose.prod.yml`, le `docker-compose.yml` restant la boucle de développement.
**Le SPA et l'API sont servis sous la même origine** : `/` vers le conteneur frontend, `/api/` vers
l'API en préservant le préfixe `/api/v1`. Le CORS cesse d'être un mécanisme de production et
redevient ce qu'il est, un filet pour les appels croisés qui ne devraient plus exister.
**nginx lit toujours les deux mêmes fichiers**, `/etc/nginx/tls/fullchain.pem` et `privkey.pem`.
Seule leur fabrication varie : un script `openssl` pour la démonstration, le `--deploy-hook` de
certbot quand un domaine existera. La configuration nginx ne connaît pas la différence et n'aura
pas à changer le jour de la bascule.
**Le proxy pose HSTS et CSP**, que l'application refuse de poser. Ce refus est verrouillé par
`tests/api/test_hardening.py::test_the_application_never_sets_hsts_itself` : l'application ne peut
pas savoir si elle est jointe en HTTPS, le terminateur, si.
## Pourquoi Compose et pas l'ingress k3s
Le module `infra/terraform/modules/k3s/` installe un cluster et rien d'autre. Il ne déclare que le
provider `null`, aucun namespace, aucun déploiement, aucun service, aucun ingress, et il n'a jamais
été appliqué. Passer par un ingress supposait d'abord de combler tout ce qui manque entre les deux
topologies : un registre d'images alimenté, des manifestes pour le front, l'API et la base, un
stockage persistant pour PostgreSQL. C'est le chantier que `10-infra.md` nomme « le trou entre les
deux topologies », et il ne tient pas dans le jalon.
Compose, lui, fait déjà tourner les quatre services sur un réseau commun. Le proxy y entre comme un
cinquième service, sans rien déplacer. La décision de désactiver Traefik reste valable : le choix
d'ingress n'est pas tranché ici, il est repoussé avec le reste de la bascule Kubernetes.
## Ce que le proxy n'expose pas, et pourquoi c'est structurel
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
pas sous le préfixe `/api`. Avec un routage où seul `/api/` part vers l'API, ils tombent dans
`location /`, donc sur le SPA, donc hors d'atteinte publique. Aucune règle de blocage n'est
nécessaire, et il n'y en a pas : le jour où quelqu'un routera la racine vers l'API pour « réparer »
Swagger, il publiera les métriques avec.
## Conséquences
- `APP_ENV`, `APP_DEBUG`, `APP_CORS_ORIGINS`, `APP_TRUST_PROXY_HEADERS` et le TLS changent
ensemble, dans le même fichier. Hors `local`, la configuration refuse de démarrer sans origine
CORS, et le cookie devient `__Secure-ev_refresh`.
- `APP_TRUST_PROXY_HEADERS` passe à vrai, et le proxy écrit `X-Forwarded-For` avec
`$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que
lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP
du proxy, c'est-à-dire globalement.
- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire
`request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de
`request.client` du backend, en dernier recours quand l'en-tête est absent.
- Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20
requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret,
`login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en
sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule
adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration.
- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires
d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela :
`<link rel="stylesheet" media="print" onload="this.media='all'">`. La feuille serait restée en
`media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans
`angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de
composants injectés à l'exécution.
- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient
donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être
amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique
couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`.
- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle
sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe
donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent
couvre l'hôte demandé, ce qui est la condition réelle à tenir.
- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend
sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502.
- La ligne API8 transport de `owasp-traceabilite.md` se referme.
- **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas
été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur
avertit, et c'est la situation réelle du projet, pas un raccourci.
- Le proxy résout ses cibles par le résolveur interne de Docker plutôt que par un bloc `upstream`,
sans quoi recréer le seul conteneur backend suffirait à produire des 502 jusqu'au rechargement.
## Alternatives écartées
- **Ingress k3s avec cert-manager** : la bonne cible, et elle reste la cible. Elle suppose un
registre et des manifestes qui n'existent pas, à quatre jours du rendu.
- **Étendre le `nginx.conf` du conteneur frontend** avec un `location /api` et l'écoute TLS :
moins de pièces, mais les certificats entrent dans l'image du front et tout rebuild du front
redéploie le terminateur TLS. La séparation des cycles de vie vaut le conteneur supplémentaire.
- **Traefik ou Caddy**, qui automatisent ACME : ils déplacent le problème sans le résoudre, le
défi HTTP-01 échouant pour la même raison. Et l'issue nomme Nginx.
- **Let's Encrypt par défi DNS-01** : fonctionne derrière une IP privée, mais exige un domaine
possédé et un jeton d'API chez le fournisseur DNS. Rouvrable sans rien changer à la
configuration nginx le jour où ces deux éléments existent.
- **Un `Dockerfile` de proxy** : inutile, la configuration est montée en volume. Cela évite aussi
la dépendance à un registre authentifié, piège déjà présent dans `apps/frontend/Dockerfile`.
@@ -0,0 +1,79 @@
# 0008 - Airflow exécute le code du backend en sous-processus
- Statut : accepté
- Date : 2026-09-21
## Contexte
L'issue #116 demande un DAG d'alertes. Ce qu'il a à ordonnancer existe déjà et n'est pas à
réécrire : `AlertService.detect()` et ses cinq règles (#104), puis le moteur de recommandations
(#38). Les deux vivent dans `apps/backend/app/`, et
l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a précisément décidé qu'ils y restent parce
qu'ils s'appuient sur les repositories ORM de l'API plutôt que sur du SQL brut. Les deux
sont décrits par la documentation comme « lancés à la main ».
L'image Airflow livrée par #115 ne porte que `ml/`, dans un environnement `uv` distinct
(`/opt/ml/.venv`, Python 3.14) de celui d'Airflow lui-même (Python 3.12, contraint par
apache-airflow 2.10). Les DAGs `ml_train` et `ml_score` shellent vers cet environnement. Rien
d'équivalent n'existe pour `apps/backend` : un `BashOperator` sur
`python -m app.detection.internal_alerts` échouerait en `ModuleNotFoundError`.
## Décision
**L'image Airflow porte un troisième environnement, `/opt/backend/.venv`**, construit depuis le
`pyproject.toml`, le `uv.lock` et le paquet `app/` du backend. Le DAG `alertes` shelle vers lui
exactement comme `ml_score` shelle vers `/opt/ml/.venv`.
Trois raisons :
- **Le patron existe et vient d'être revu.** #115 a posé `BashOperator` + `uv run --no-sync` +
`env -u VIRTUAL_ENV`, avec les tests d'intégrité qui le verrouillent. Introduire une seconde
forme d'appel dans le même dossier `dags/` coûterait plus cher à lire qu'un second environnement
dans le même `Dockerfile`.
- **Aucune surface réseau n'est ajoutée.** La détection n'a pas de route HTTP, contrairement à la
génération de recommandations (`POST /recommendations/generate`, rôle `admin`). En créer une pour
qu'Airflow l'appelle donnerait à l'ordonnanceur un compte administrateur de l'API, en plus des
identifiants PostgreSQL complets qu'il détient déjà, et ferait dépendre la production d'alertes
de la disponibilité du conteneur `backend`.
- **La logique reste où l'ADR 0006 l'a mise.** Le DAG n'apprend rien du domaine : ni les seuils, ni
les cinq règles, ni les clés d'idempotence. Il ne sait que l'heure à laquelle appeler.
## Conséquences
- **Airflow reçoit une `APP_SECRET_KEY` délibérément distincte de celle de l'API.** La
configuration du backend refuse de se construire sans elle (`app/core/config.py`), et
`internal_alerts.main()` appelle `get_settings()` avant toute requête pour échouer tôt. Mais la
détection ne signe ni ne vérifie aucun jeton, et Airflow permet d'exécuter du code arbitraire
depuis son interface : un Airflow compromis ne doit pas livrer la clé de signature des JWT. D'où
`AIRFLOW_APP_SECRET_KEY`, avec sa propre garde dans `airflow-init`.
- **`DATABASE_URL`, en dialecte asyncpg, rejoint `ML_DATABASE_URL`** dans l'environnement du
conteneur. Le cantonnement des rôles PostgreSQL reste la dette de
l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md), et cette décision l'alourdit d'un
consommateur de plus.
- **La CI Airflow se déclenche sur les changements du backend.** L'image le `COPY` : sans
`apps/backend/app/**`, `pyproject.toml` et `uv.lock` dans les déclencheurs du workflow, une
dépendance modifiée casserait la construction sans que rien ne le signale avant le déploiement.
En contrepartie, l'image grossit de ce que pèsent SQLAlchemy, asyncpg et pandas.
- **Aucune variable ne départage les deux environnements, et c'est voulu.** `uv` place par défaut
le venv d'un projet dans `<projet>/.venv` : `cd /opt/ml` ou `cd /opt/backend` suffit à choisir le
bon. L'image ne pose donc plus de `UV_PROJECT_ENVIRONMENT` global, hérité de #115 : il vaudrait
pour les deux projets, et `uv run` dans l'un résoudrait le venv de l'autre. Le symptôme n'est pas
une construction ratée mais un `ModuleNotFoundError` à la première tâche, d'où la vérification
d'import sans réseau que la CI fait maintenant sur chacun des deux.
- Airflow lui-même reste étranger au domaine : ni LightGBM, ni SQLAlchemy, ni FastAPI n'entrent
dans son interpréteur. C'est la propriété que #115 avait établie, et elle tient toujours.
## Alternatives écartées
- **Route HTTP `POST /alerts/detect` réservée `admin`, appelée par le DAG.** L'image ne bougeait
pas, mais Airflow détenait alors un compte administrateur de l'API, la détection devenait
tributaire du conteneur `backend`, et l'API gagnait une route d'écriture dont aucun client
humain n'a l'usage. À rouvrir si un jour un tiers doit déclencher la détection.
- **`DockerOperator` lançant l'image du backend.** Demande la socket Docker de l'hôte dans le
conteneur Airflow, c'est-à-dire un équivalent root sur la machine, pour un service qui permet
déjà d'exécuter du code depuis son interface. Le provider n'est d'ailleurs pas installé.
- **Réécrire les cinq règles en SQL dans le DAG.** Contredit frontalement l'ADR 0006, duplique le
domaine, et fait diverger les deux copies au premier changement de seuil.
- **Monter `apps/backend` en volume plutôt que le copier.** L'environnement ne serait plus figé à
la construction, `uv` resynchroniserait au premier lancement, et la CI ne prouverait plus rien
de ce qui tourne réellement.
+32 -14
View File
@@ -46,6 +46,7 @@ flowchart TB
navigateur["Navigateur"]
subgraph machine["Machine on-premise"]
proxy["Reverse proxy Nginx<br/>:80 et :443"]
front["Frontend Angular 22<br/>apps/frontend"]
api["API FastAPI<br/>apps/backend"]
db[("PostgreSQL 17<br/>TimescaleDB")]
@@ -54,10 +55,12 @@ flowchart TB
grafana["Grafana"]
end
navigateur --> front
navigateur --> proxy
proxy --> front
proxy --> api
front -.-> api
api --> db
airflow -.-> db
airflow --> db
prom -.-> api
grafana -.-> db
grafana -.-> prom
@@ -67,6 +70,11 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
[30-frontend.md](30-frontend.md).
Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le
reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire.
@@ -74,18 +82,20 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|---|---|---|---|---|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement (features par lags/moyennes glissantes, baseline de persistance saisonnière, suivi MLflow local), voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Scoring, endpoint et surveillance de dérive pas encore construits |
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
## Flux bout en bout
Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base.
Statut : `Cible`. Ce flux d'ingestion (Source → Airflow → hypertable) n'existe pas encore : les
deux DAGs livrés à ce jour (`ml_train`/`ml_score`, issue #115) orchestrent le pipeline ML, pas
l'ingestion. Seule la base tourne réellement parmi les maillons ci-dessous.
```mermaid
sequenceDiagram
@@ -137,6 +147,11 @@ consolidée.
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige
80 vers 443, sert le SPA et l'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**
distincte de celle de l'application. Voir
[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root`
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
`.example`.
@@ -150,13 +165,10 @@ consolidée.
arrêteraient une application compromise. Même raison de report.
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
agir sur le site B. C'est la limite connue du modèle.
- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore.
- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas
l'infrastructure.
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
est livré et documenté mais pas exercé.
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP
simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir
[31-contrat-authentification.md](31-contrat-authentification.md).
## Décisions structurantes
@@ -165,3 +177,9 @@ Elles vivent dans `../adr/`, pas ici.
| ADR | Objet |
|---|---|
| [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` |
| [0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
| [0003](../adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête |
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
+139 -8
View File
@@ -1,12 +1,13 @@
# Infrastructure
Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre les deux.
Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
| Topologie | Sert à | Statut |
|---|---|---|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
| k3s single-node | Déployer sur le serveur on-premise | `En cours` |
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
| k3s single-node | Cible à terme | `En cours` |
## Poste de développement
@@ -40,15 +41,135 @@ seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste ave
des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le
port 8000, ils ne se lancent donc pas ensemble.
Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
- `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de
l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message.
- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
le compose. Voir [`db/README.md`](../../db/README.md).
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
## Cible de déploiement
### Airflow (issues #115 et #116)
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
airflow-up`, pas dans `make dev`) :
| Service | Rôle | Points notables |
|---|---|---|
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès |
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` |
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
la construction), distincts du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent
vers ces venvs plutôt que d'importer LightGBM, MLflow ou SQLAlchemy dans le process Airflow.
Le choix et ses contreparties sont dans
l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
| DAG | Planification | Ce qu'il lance, et où |
|---|---|---|
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
tâche greffée : 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. Le décalage est donc une convention et non une
garantie : le plafond de `ml_score` est de 30 minutes, et un scoring qui déborde de `:15` prive
`anomaly` de la `prediction` de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre
la couvre encore. Les quatre autres règles ne s'en aperçoivent pas.
Ses deux tâches s'enchaînent en revanche (`recommendation.alert_id` est une clé étrangère `NOT
NULL`), et toutes deux sont rejouables sans risque : l'idempotence est portée par la base,
`uq_alert_source_reference` et `uq_recommendation_alert_rule`. Chacune a 2 tentatives, 2 minutes
d'attente entre elles et un plafond de 5 minutes **par tentative** : au pire, reprises comprises,
l'enchaînement occupe 38 minutes, ce qui le garde sous le pas horaire qu'un `max_active_runs=1`
rend contraignant.
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou
`AIRFLOW_APP_SECRET_KEY` vides).
Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en
dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est
**délibérément différente** de celle de l'API. La configuration du backend refuse de se construire
sans clé, mais la détection ne signe ni ne vérifie aucun jeton : un Airflow compromis, qui permet
déjà d'exécuter du code depuis son interface, ne doit pas livrer par-dessus la clé de signature
des JWT.
**Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision
prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un
cron déploierait silencieusement un modèle dégradé. Tant que ce garde-fou n'existe pas, le
déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `max_active_runs=1`
(pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes.
CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et
tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/` et `apps/backend/`, une
modification de l'un ou de l'autre peut donc la casser, d'où leurs chemins dans les déclencheurs)
avant de vérifier que les deux environnements s'y importent sans réseau.
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois :
`docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE DATABASE airflow;"`.
`libgomp1` est installé explicitement dans l'image (`apt-get`, en root) : l'image Airflow de base
est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans quoi l'erreur
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
construction de l'image.
## Machine cible, exécution Docker
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de
l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
```mermaid
flowchart LR
navigateur["Navigateur"]
subgraph machine["Machine on-premise"]
proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"]
front["service frontend<br/>nginx statique :3000"]
api["service backend<br/>uvicorn :8000"]
db[("service db<br/>:5432")]
mail["service mailpit"]
end
navigateur -->|"HTTPS"| proxy
proxy -->|"/"| front
proxy -->|"/api/"| api
api --> db
api --> mail
```
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont
plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables
par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et
la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md).
Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent pas :
- Servir le SPA et l'API sous la même origine est ce qui rend le cookie `__Secure-ev_refresh`
utilisable. Sans cela, `apiUrl: '/api/v1'` ne mène nulle part une fois en conteneur.
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
compte sur l'IP du proxy et devient globale.
## Cible à terme, k3s
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
été appliqué.
@@ -104,6 +225,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
## Ports et noms
@@ -112,12 +235,16 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris |
| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` |
| API | `8000` | Identique en conteneur et hors conteneur |
| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend |
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME |
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
## Le trou entre les deux topologies
## Le trou vers k3s
Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose
construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première
@@ -125,7 +252,11 @@ question à trancher, avant toute ressource Kubernetes.
## Questions ouvertes
- **Quel ingress** remplace Traefik, et qui termine le TLS.
- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par
l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la
topologie Compose, pas pour Kubernetes.
- **Quel nom de domaine public**, sans lequel Let's Encrypt reste hors d'atteinte et le certificat
reste auto-signé.
- **Quel registre d'images**, et comment il est alimenté sans CI.
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
cluster ou à côté.

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