Compare commits

..
174 Commits
Author SHA1 Message Date
ineszang44 6301ffedd7 Merge branch 'dev' into feat/deploy 2026-09-23 09:50:54 +02:00
Johan LEROYandGitHub 59f050ec5e Merge pull request #147 from ineszang/test/integration-api-db-ml
test(ml,backend): tests d'intégration API ↔ DB ↔ ML, et surveillance de dérive
2026-09-22 16:52:55 +02:00
Johan LEROY 6e9c830557 Fusionne dev dans test/integration-api-db-ml
Quatre conflits, tous additifs, nés du DAG `mock_api_import` (#146) arrivé sur `dev` pendant
que cette branche ajoutait `derive` : la liste des DAGs du README, celle de la vue d'ensemble
et du tableau d'infrastructure, et `DAG_IDS`/`TACHES` dans les tests d'intégrité. Les six DAGs
sont conservés de part et d'autre.

Collision que git ne voyait pas : `dev` a reçu un ADR 0011 et un 0012 (procédure de
déploiement, état de la VM ENI) pendant que cette branche en ajoutait un autre sous le même
numéro. L'ADR de la surveillance de dérive devient 0013, avec ses onze références, et la table
de `docs/README.md` reprend les trois.
2026-09-22 16:40:06 +02:00
Meryemel-ghamandGitHub 5a3c526856 Merge pull request #146 from ineszang/feat/dag-mock-api-import
feat(airflow): orchestre l'import de la Mock API
2026-09-22 16:32:45 +02:00
Johan LEROY 314e3b72c0 fix(ml,backend): corrige la revue, le typage du drapeau CSV et la portée du biais
`load_from_csv` gardait un `astype(bool)` sur `is_working_hours`, joué avant `_typer` :
une case vide du CSV arrivait en `NaN` et en ressortait `True`, soit une heure ouvrée
inventée. Le chemin base était corrigé, pas celui-ci, et rien ne le couvrait. La ligne
disparaît, et `_typer` ramène désormais les colonnes de `FLAG_COLUMNS` à `float64` quel
que soit le contenu lu : sans cela le dtype dépendait de l'écriture du fichier (`0`/`1`
contre `True`/`False`) et de la présence d'un trou, et l'égalité de schéma entre les deux
chargeurs que promet ML-START n'était vraie que par accident du jeu de test.

`Seuils.seuil_biais` valait `0` et `_verdict` exigeait `> 0` : la règle était inerte
partout, CLI et DAG compris, et aucun test ne l'exerçait. Elle reste désactivée par
défaut, parce qu'un seuil en kWh ne se transpose pas d'un bureau de 10 kWh à une usine
de 1 000 kWh et qu'aucune valeur n'a été calibrée sur la vraie série, mais `--bias-threshold`
la rend atteignable et l'ADR 0011 porte l'arbitrage. Trois tests couvrent le chemin :
inerte par défaut, dérive au-delà du seuil réglé, et priorité de la MAE sur le biais.

Deux lignes de doc devenues fausses au passage : la signature de `load_recent_from_database`
dans ML-START, qui omettait `until` devenu obligatoire, et la ligne `bias` de 20-backend,
qui laissait croire que la métrique décide du verdict.
2026-09-22 16:25:02 +02:00
Meryemel-gham e118c008bf fix(airflow): fiabilise l'import horaire de la Mock API
Airflow / Construction de l'image (push) Successful in 1m4s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Tests exigeant une base (push) Failing after 4m55s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m49s
Backend / Lint, typage et tests (push) Successful in 10m12s
Backend / Audit des dépendances (push) Successful in 9m36s
SonarQube / build-front (push) Successful in 9m35s
SonarQube / test-ml (push) Failing after 5m37s
SonarQube / build-back (push) Successful in 9m46s
SonarQube / test-front (push) Failing after 5m4s
SonarQube / test-back (push) Failing after 5m9s
SonarQube / SonarQube (push) Skipped
2026-09-22 16:24:05 +02:00
Meryemel-gham d86224a0f7 feat(airflow): orchestre l'import de la Mock API 2026-09-22 15:47:01 +02:00
Johan LEROY f21a843fc2 Fusionne dev dans test/integration-api-db-ml
La PR #123 (MLflow) est arrivée sur dev entre-temps. Un seul conflit, la liste
.PHONY du Makefile : elle garde `migrate-test` d'ici et `mlflow-up` de dev, les
deux cibles existant chacune de leur côté.

Rien d'autre ne se recoupe : le test de chaîne passait déjà son propre
`--mlflow-tracking-uri` sur un SQLite jetable, et `modele_jetable` entraîne son
Booster sans passer par `train()`, qui journalise dans MLflow sans garde.
2026-09-22 15:34:38 +02:00
ineszangandGitHub 6b3908d321 Add files via upload 2026-09-22 15:34:33 +02:00
ineszang44 da5d2d838a feat: saisie des logins pour accéder au vault 2026-09-22 15:26:30 +02:00
Johan LEROY b16861e211 Fusionne dev dans test/integration-api-db-ml
Un seul conflit, docs/architecture/50-cicd.md : les deux côtés ajoutaient une
section au même endroit, après « Secrets ». Les deux sont conservées. Celle de
la branche, « Pourquoi le job d'intégration ML installe aussi le backend »,
remonte sous « Le job d'intégration, et pourquoi il ne suffisait pas d'un
postgres », dont elle est le prolongement : posée après « Secrets », elle en
devenait une sous-section.

openapi.json régénéré : dev a renommé le schéma de sécurité « Jeton d'accès »
en « JetonAcces » pour l'analyseur de contrat de ZAP, et la route
/api/v1/monitoring/drift ajoutée ici portait encore l'ancien nom dans le
contrat figé. Aucune fusion textuelle ne pouvait le voir.
2026-09-22 15:22:28 +02:00
ValentinDeFariaandGitHub 57b9735804 Merge pull request #123 from ineszang/feat/entrainement-du-modele
feat(ml): enregistrer le modèle dans le MLflow Model Registry
2026-09-22 15:21:24 +02:00
PhyriosandGitHub c007ea01bd Merge pull request #140 from ineszang/feat/scan-dast-owasp-zap
ci(backend): ajoute un scan DAST OWASP ZAP de l'API avec un compte le…
2026-09-22 15:17:18 +02:00
Johan LEROY 5472b19504 fix(backend): repare ce que la CI a trouve sur les tests de derive
Deux causes distinctes, toutes deux invisibles sans base.

`creer_lecture` ne posait pas `consumption_kwh` : l'override etait ignore en silence, la colonne
restait nulle, et la jointure de derive, qui ecarte les lectures sans mesure, ne trouvait donc
aucune paire. Le helper accepte desormais ce champ, nul par defaut, ce qui ne change rien pour
les dix fichiers qui l'utilisent deja.

`test_the_operator_rank_opens_nothing_more_than_the_reader_rank` figeait l'egalite des deux rangs
en annoncant, dans son propre commentaire, qu'il devait sonner « le jour ou une route d'operateur
arrive ». Ce jour est arrive avec `GET /monitoring/drift`. Le test compare maintenant chaque
route a ce que `ROLE_MINIMUM` lui reserve : il continue d'attraper une route d'operateur ajoutee
sans etre classee, et attrape en plus une garde d'operateur posee par erreur sur une route de
lecture.
2026-09-22 15:08:40 +02:00
Valentin 44163bfb98 fix(ml): corrige le build MLflow (psycopg2), le garde-fou Makefile, la doc et la fuite de mot de passe
ML / Analyse statique de sécurité (push) Successful in 6s
SonarQube / test-ml (push) Failing after 6m4s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / build-back (push) Successful in 9m50s
ML / Lint, typage et tests (push) Successful in 11m41s
SonarQube / test-front (push) Failing after 5m1s
SonarQube / test-back (push) Failing after 5m11s
SonarQube / SonarQube (push) Skipped
2026-09-22 15:04:21 +02:00
Dorian ea8f9d0a3a fix(ci): applique aussi le chmod du fichier d'authentification DAST via sudo 2026-09-22 14:57:29 +02:00
Dorian f58fc4ba81 fix(ci): corrige les permissions du fichier d'authentification qui bloquait le scan DAST 10 minutes 2026-09-22 14:31:29 +02:00
Johan LEROY cb961ec2c5 fix(ml): borne la fenetre de scoring a l'instant demande, pour que --now rejoue l'historique
`load_recent_from_database` n'avait qu'une borne basse. `build_scoring_frame` repartait donc de
la derniere lecture de toute la table quel que soit `--now` : `target_at` valait toujours
"fin du jeu + 1h", et `_age = instant - derniere_lecture` devenait negatif, ce qui passait le
seuil de peremption sans rien signaler.

Consequence concrete : sur le jeu historique, arrete au 31/12/2024, aucune boucle de rattrapage
ne pouvait produire une prevision dont le realise existe deja. La surveillance de derive livree
par la migration precedente n'aurait donc rien eu a comparer en demonstration.

`until` est desormais obligatoire sur ce chargeur, ce qui interdit de l'oublier, et le mode CSV
filtre symetriquement. En exploitation rien ne change, aucune lecture n'etant posterieure a
l'heure courante.
2026-09-22 14:29:03 +02:00
Johan LEROY 68239371f6 docs(ml,backend,etl): ordonnance la derive et corrige ce que le depot disait faux
Trois phrases du depot annonçaient une surveillance de derive inexistante, et une quatrieme
disait qu'aucune base PostgreSQL n'etait joignable pour tester le chargement ML. Les quatre
sont maintenant fausses, donc reecrites plutot que laissees en dette.

- ADR 0011 : ou vit le calcul et pourquoi pas dans `ml/`, les deux dedoublonnages qu'impose la
  jointure, et quatre alternatives ecartees avec la contrainte qui les interdit (la metrique
  MLflow n'est pas la meme grandeur, `alert` borne ses valeurs et refuse un site nul, Prometheus
  n'a pas de collecteur, ne rien persister ne repond pas a la question du jury).
- DAG `derive` quotidien, hors du DAG `alertes` : un echec de derive y ferait croire que la
  detection a echoue, et la fenetre de 168 h ne se recalcule pas toutes les heures.
- ML-START : la limite de `--now` est dite au lieu d'etre decouverte en demonstration. Elle ne
  decale que l'instant de reference, pas la fenetre de lecture, donc aucun rattrapage ne peut
  fabriquer de paires prevu/realise sur un jeu fige.
- 50-cicd : pourquoi le job ML installe aussi le backend (le schema n'a qu'une source), et ce
  que coute le filtre de chemins qui l'accompagne.
2026-09-22 14:27:13 +02:00
Johan LEROY 9bf2f27127 feat(backend): surveille la derive du modele de prevision
EC06 attendait une reponse a « comment savez-vous que le modele se degrade ? ». Elle n'existait
nulle part : `docs/architecture/00-vue-ensemble.md` et `docs/ML-START.md` le disaient tous les
deux.

Le calcul vit dans le backend, et `ml/` ne gagne pas une ligne. Trois raisons : `prediction`
n'est pas dans le perimetre de lecture que `ML_DATABASE_URL` vise (ADR 0003 et ML-START le
bornent a `reading` et `site`) ; l'alignement prevu contre realise existe deja une fois ici,
dans `AlertService._detect_anomaly`, et le dupliquer en SQL brut creerait une seconde source de
verite, ce que l'ADR 0006 refuse ; et FastAPI continue de ne jamais faire tourner LightGBM.

Ce qui est mesure : la jointure `prediction` x `reading` sur `(site_id, target_at)`, avec un
`DISTINCT ON` des deux cotes. Les runs de scoring s'empilent volontairement, et
`uq_reading_source` autorise deux lectures au meme instant quand la source differe : sans ce
dedoublonnage, la meme heure pesait plusieurs fois dans la moyenne. La fenetre est fermee a
droite par un delai de grace, sinon la derniere heure, dont le realise n'est pas encore
ingere, ferait chuter la couverture a chaque execution.

Le verdict a trois valeurs, pas deux : avec trois points on ne declare pas une derive, on dit
qu'on ne sait pas. La comparaison se fait entre deux fenetres vives de meme duree, jamais
contre la metrique loguee a l'entrainement : celle-ci mesure un backtest a meteo connue, le
scoring prevoit une heure dont la meteo ne l'est pas.

`drift_report` porte une ligne par site plus une ligne globale, que `site_id` a NULL designe.
L'idempotence passe par un index a `coalesce` et non par une contrainte d'unicite, sans quoi
deux lignes globales ne seraient jamais egales.
2026-09-22 14:22:54 +02:00
Johan LEROY 568060a903 test(backend): traverse l'API jusqu'a PostgreSQL sur les sites et les recommandations
Les tests d'API remplacaient tous leur service par un double : rien ne prouvait que
`endpoint -> service -> repository -> SQL` rende ce que l'endpoint serialise. Seules
l'authentification et la matrice de roles traversaient vraiment la base.

`tests/api/conftest.py` seme un jeu metier valide en base et nettoie derriere lui. Il valide
ses ecritures, contrairement aux fixtures de `tests/repositories` : un endpoint ouvre sa
propre session et ne verrait pas une transaction en cours. L'isolation vient de la marque
portee par chaque `site_id`, jamais d'un total : ces routes listent toute la base.

Les recommandations d'abord, parce que `POST /generate` est la seule route d'ecriture : son
idempotence tient a une contrainte d'unicite et a un `on_conflict_do_nothing`, invérifiables
hors base, et sa relecture par une seconde requete HTTP est la seule assertion du depot qui
prouve que la validation atteint le disque. Cote sites, le depart des ex aequo par
`reading_id` quand deux sources ecrivent la meme heure ne peut se demontrer qu'ainsi.
2026-09-22 14:13:00 +02:00
Dorian 2c7ee2c064 Merge remote-tracking branch 'origin/dev' into feat/scan-dast-owasp-zap 2026-09-22 14:11:31 +02:00
Johan LEROY aa4af62290 test(ml,backend): couvre ML vers DB, puis la chaine complete jusqu'a l'API
Le pipeline ML n'avait aucun test touchant PostgreSQL : `ml/README.md` le disait, faute de
base joignable en CI. Le marqueur `integration` de `ml/pyproject.toml` etait declare et porte
par zero test.

- `ml/tests/conftest.py` : deux fixtures d'acces a la base, jamais interchangeables.
  `connexion_ml` annule sa transaction, `parc` valide ses ecritures parce que `run_scoring`
  ouvre sa propre connexion et ne verrait rien d'autre. Garde sur le nom de base, marque uuid
  sur chaque site, nettoyage dans l'ordre des cles etrangeres.
- `test_data_integration.py` : les neuf colonnes du contrat confrontees au schema Alembic
  reel, la borne `since`, l'ordre de tri dont dependent des lags positionnels, et le typage
  des colonnes entierement nulles.
- `test_score_integration.py` : les contraintes de `prediction` vues depuis le code qui
  ecrit, l'empilement volontaire de deux runs, et `run_scoring` de bout en bout sur un
  booster reel.
- `apps/backend/tests/test_chaine_ml_api.py` : lance les vrais binaires `enervision_ml.train`
  et `.score` en sous-processus, comme les DAGs, puis relit par `GET /api/v1/predictions`.
  Marqueur `chaine` distinct : le job `integration` du backend n'a pas l'environnement de ml/.
- `ml.yml` : job `integration`, seul du depot a reunir les deux environnements uv et une base.
  Ses `paths` incluent les migrations du backend, sans quoi le schema deriverait du SQL du
  pipeline sans que rien ne casse.
- Makefile : `migrate-test`, qui manquait (`enervision_test` n'a jamais recu de table),
  `ml-test-integration` et `test-chaine`.
2026-09-22 14:10:50 +02:00
Johan LEROY 5b5c97532d fix(ml): type is_working_hours pour que LightGBM accepte une lecture sans valeur
`reading.is_working_hours` est nullable et fait partie des features. Une seule lecture a
NULL dans la fenetre suffisait a rendre la colonne `object` au retour de `pd.read_sql`, et
LightGBM refuse alors de construire son Dataset : "pandas dtypes must be int, float or
bool". La panne n'arrivait qu'au scoring, sur la vraie base, jamais en test.

`_typer` coerce donc aussi cette colonne, comme les colonnes mesurees : NaN vaut valeur
manquante, que LightGBM gere nativement. Effet de bord voulu, les deux chargeurs rendent
enfin le meme dtype : `load_from_csv` faisait un `astype(bool)` qui ecrasait silencieusement
une valeur absente en `False`.
2026-09-22 14:10:35 +02:00
Dorian e220f8f0c6 fix(ci,backend): securise le jeton du scan DAST, seme des donnees et refait ses garde-fous 2026-09-22 14:08:51 +02:00
Johan LEROYandGitHub fb4235df3a Merge pull request #144 from ineszang/feat/terraform-provisionnement
feat(infra): Terraform provisionne la VM, GitHub Actions continue de déployer
2026-09-22 12:39:12 +02:00
Johan LEROYandClaude Opus 5 357cf996ed fix(infra): donne au runner un nom distinct de son label
Infra / Formatage et validation Terraform (push) Successful in 9s
--name reprenait runner_labels : un runner et son label portaient le meme
eni-g3. Deux machines qui reprendraient cette racine s'enregistreraient sous
le meme nom, ce que GitHub refuse. Le nom vaut desormais celui de l'hote,
resolu sur la machine, et runner_nom permet de le forcer.

Point 2 de la revue de la PR #144.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:36:38 +02:00
PhyriosandGitHub 4fb8b5a784 Merge pull request #143 from ineszang/fix/deploiement-airflow-3
fix(infra,ci): aligne le provisionnement et le déploiement sur Airflow 3
2026-09-22 11:44:16 +02:00
Johan LEROYandClaude Opus 5 c1f9dec5fe chore(git): retire le motif airflow-webserver.pid du .gitignore
Airflow 3 n'a plus de webserver : le fichier n'est plus produit. Dernier point
de la revue de la PR #143.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:42:25 +02:00
Dorian d9103ee4ed fix(ci): prune-cache set false 2026-09-22 11:29:54 +02:00
Johan LEROY fca36649f9 ci(infra): épingle setup-terraform sur un SHA de commit
Action tierce sur un workflow qui tourne avec les droits du dépôt : un tag
mobile se redéplace. Constat SonarCloud githubactions:S7637, seul écart de la
quality gate sur la PR.
2026-09-22 11:29:24 +02:00
Johan LEROY 0db69fd023 feat(infra): Terraform provisionne la VM, GitHub Actions continue de déployer
Le seul Terraform du dépôt installait un cluster k3s que rien ne consomme, et
sa racine ne passait même pas `terraform init` : depuis Terraform 1.x, un
provisioner `destroy` impose que tout le bloc `connection` ne lise que `self`,
et celui du module k3s lisait `var.*`. Il est corrigé en batissant la connexion
sur `triggers`, où ne figurent que l'adresse, le port, l'utilisateur et le
chemin de la clé : jamais la clé ni un mot de passe.

`environments/vm-eni` provisionne la machine qui porte la recette et la
production : Docker et le plugin Compose, `scripts/provision-host.sh`, puis
l'enregistrement du runner. Rien n'y construit d'image ni ne lance de
conteneur, et un `apply` n'interrompt pas la stack qui tourne. Le déploiement
continu ne bouge pas : `deploy.yml` reste le seul chemin de livraison.

`environments/dev` devient `k3s-cible`, parce qu'il provisionnait un cluster et
pas un environnement applicatif, et que `rec` et `prod` vivent sur la même
machine. `environments/prod`, dossier vide, disparaît.

`infra.yml` formate et valide chaque racine à chaque push : c'est l'absence
d'un tel job qui a laissé ce Terraform cassé sans que rien ne le dise. La
boucle parcourt `environments/*`, une racine ajoutée est couverte sans y
toucher.

ADR 0010 pour la frontière entre provisionnement et livraison, et la doc
d'architecture alignée dessus.
2026-09-22 11:27:21 +02:00
Johan LEROY be6be42947 fix(infra,ci): aligne le provisionnement et le déploiement sur Airflow 3
La migration Airflow 3 est arrivée sur `dev` après l'écriture du chemin de
déploiement, qui en a gardé quatre traces fausses, invisibles en CI puisque
aucun job ne joue ce chemin.

`provision-host.sh` substituait `AIRFLOW_WEBSERVER_SECRET_KEY`, clé disparue.
`AIRFLOW_API_SECRET_KEY` et `AIRFLOW_JWT_SECRET` restaient donc à `change_me`
dans le `.env` posé sur la machine, et le `:?` d'`airflow-init` ne voit pas
une valeur d'exemple : la stack aurait démarré avec un secret de session et un
secret JWT prévisibles. Le `.env` est maintenant écrit après contrôle, et le
script refuse de le poser s'il reste un `change_me` hors `APP_MOCK_API_*`.

`make services-up` démarrait `airflow-webserver`, service supprimé par la
migration ; seul `airflow-up` avait été aligné.

L'overlay posait `AIRFLOW__WEBSERVER__WORKERS`, sans effet en Airflow 3 où la
section est `[api]`. Le réglage disparaît plutôt que d'être renommé : le défaut
y vaut un worker, moins que les deux qu'on visait.

Le diagnostic d'échec de `deploy.yml` lisait les journaux sans l'overlay, donc
sans service `proxy` : il échouait avant d'imprimer quoi que ce soit.
2026-09-22 11:17:02 +02:00
Dorian 3ddeb24207 Merge remote-tracking branch 'origin/dev' into feat/scan-dast-owasp-zap 2026-09-22 11:16:51 +02:00
Valentin 8760ebc701 fix(ml): applique les corrections de la review MLflow (securite, documentation, robustesse) 2026-09-22 10:41:02 +02:00
Valentin a013dfa87f Merge remote-tracking branch 'origin/dev' into feat/registry-modele-prediction 2026-09-22 10:11:24 +02:00
Johan LEROY b5993f994e chore(deps): aligne le runner nginx du frontend sur 1.31-alpine
Même image que le proxy de docker-compose.prod.yml depuis #126 : un seul
nginx à suivre pour les deux conteneurs qui en embarquent un.
2026-09-22 09:13:03 +02:00
Johan LEROY 091ee344d1 refactor(etl): aligne la DAG historical_import sur le SDK Airflow 3
Même mouvement que pour ml_train, ml_score et alertes (#135) : DAG depuis
airflow.sdk, BashOperator depuis le provider standard, et la planification
lue sur dag.schedule puisque les timetables n'exposent plus summary. Les
anciens imports fonctionnaient encore, avec un avertissement de dépréciation.
2026-09-22 09:05:53 +02:00
Johan LEROY f81959c1a9 Merge branch 'main' into dev
Rapatrie #135 : migration vers Airflow 3.3.2 (api-server, dag-processor,
secret JWT, FabAuthManager, DAGs et tests sur le SDK). Conflit Makefile :
la cible airflow-up garde le prérequis db-ensure-airflow de dev et les
services renommés de main.
2026-09-22 09:05:50 +02:00
Johan LEROYandGitHub 5c361c6952 Merge pull request #135 from ineszang/dependabot/uv/etl/airflow/apache-airflow-3.3.0
chore(deps): bump apache-airflow from 2.10.4 to 3.3.0 in /etl/airflow
2026-09-22 09:04:19 +02:00
Johan LEROY a27e0c6535 Merge branch 'main' into dev
Rapatrie dans dev les mises à jour de dépendances mergées sur main :
#142 (SonarQube ignoré pour Dependabot), #129 checkout v7, #130 setup-uv v7,
#132 setup-node v7, #128 upload-artifact v7, #131 download-artifact v8,
#127 Node 26 (image et CI frontend), #126 nginx 1.31-alpine, #133 pandas 3.0.6
et ruff 0.16.8, #134 Angular 22.1.7, jsdom 30, prettier 3.9.8 (TypeScript 6 et
vitest 4 conservés, majeures ignorées côté Dependabot).

Conflit README.md : statuts « En place » de main, « Quatre DAGs » de dev (#138).
2026-09-22 09:03:17 +02:00
Johan LEROY 7a97a4d1ac docs(readme): le frontend construit sur Node 26
Suite à #127 : image node:26-alpine3.22 et CI frontend sur Node 26.
2026-09-22 09:03:12 +02:00
Johan LEROYandGitHub 20abcfee33 Merge pull request #134 from ineszang/dependabot/npm_and_yarn/apps/frontend/frontend-dependencies-e5a1f9e295
chore(deps): bump the frontend-dependencies group in /apps/frontend with 12 updates
2026-09-22 09:02:17 +02:00
Johan LEROY 3d6d2eb760 style(infra): reflow un commentaire du compose Airflow à 100 colonnes 2026-09-22 09:01:07 +02:00
Johan LEROY 154deca511 docs(architecture): décrit les composants Airflow 3
Quatre services au lieu de trois (api-server, dag-processor), le secret JWT
partagé et le choix du FabAuthManager. Le Python 3.12 d'etl/airflow est celui
de l'image retenue, plus une limite d'Airflow.
2026-09-22 09:01:07 +02:00
Johan LEROY a577356198 feat(infra): fait tourner Airflow 3 dans la stack Compose
Image apache/airflow:3.3.2-python3.12. Le webserver devient l'api-server
(healthcheck /api/v2/monitor/health) et le dag-processor est un service à
part : depuis Airflow 3 le scheduler ne parse plus les DAGs.

Les tâches passent par l'Execution API de l'api-server avec un jeton signé
par AIRFLOW_JWT_SECRET, nouveau secret du .env partagé entre conteneurs.
AIRFLOW_WEBSERVER_SECRET_KEY devient AIRFLOW_API_SECRET_KEY. airflow-init
refuse toujours de démarrer si l'un des secrets manque.

FabAuthManager explicite : le SimpleAuthManager par défaut ne sait pas créer
le compte admin que airflow-init pose via _AIRFLOW_WWW_USER_*. Pas de
triggerer, aucun opérateur déférable dans les DAGs.
2026-09-22 09:01:07 +02:00
Johan LEROY 19ff152445 refactor(etl): migre les DAGs et leurs tests vers le SDK Airflow 3
DAG et BaseOperator viennent d'airflow.sdk, BashOperator du provider
standard (airflow.operators.bash n'est plus qu'un alias déprécié). DagBag
s'importe depuis airflow.dag_processing et ne prend plus include_examples,
les exemples étant déjà coupés par la configuration de conftest.py.

Les timetables n'exposent plus summary : la planification se lit sur
dag.schedule (None) et timetable.expression (cron normalisé).
2026-09-22 09:01:07 +02:00
Johan LEROY bf3584182f chore(deps): passe apache-airflow à 3.3.2
Dernier correctif de la branche 3.3 (17 septembre 2026) plutôt que le 3.3.0
proposé par Dependabot. Le verrou est régénéré par uv lock ; le provider
standard et le Task SDK, requis par Airflow 3, y figurent déjà.
2026-09-22 09:01:07 +02:00
dependabot[bot]andJohan LEROY 28e8260216 chore(deps): bump apache-airflow from 2.10.4 to 3.3.0 in /etl/airflow
Bumps [apache-airflow](https://github.com/apache/airflow) from 2.10.4 to 3.3.0.
- [Release notes](https://github.com/apache/airflow/releases)
- [Changelog](https://github.com/apache/airflow/blob/main/docker-stack-docs/changelog.rst)
- [Commits](https://github.com/apache/airflow/compare/2.10.4...3.3.0)

---
updated-dependencies:
- dependency-name: apache-airflow
  dependency-version: 3.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 09:01:07 +02:00
Johan LEROYandGitHub e90e563a1c Merge pull request #133 from ineszang/dependabot/uv/apps/backend/backend-dependencies-b3e2c9fee8
chore(deps): bump the backend-dependencies group in /apps/backend with 2 updates
2026-09-22 08:59:17 +02:00
Johan LEROYandGitHub d7ca9fd8b0 Merge pull request #126 from ineszang/dependabot/docker_compose/nginx-1.31-alpine
chore(deps): bump nginx from 1.28-alpine to 1.31-alpine
2026-09-22 08:59:06 +02:00
Johan LEROYandGitHub 67204b5eb6 Merge pull request #127 from ineszang/dependabot/docker/apps/frontend/node-26-alpine3.22
chore(deps): bump node from 24-alpine3.22 to 26-alpine3.22 in /apps/frontend
2026-09-22 08:58:54 +02:00
Johan LEROY 985466e9cc ci(frontend): teste sur Node 26, comme l'image
Frontend / Audit des dépendances (push) Successful in 6s
SonarQube / test-ml (push) Failing after 5m46s
Frontend / build (push) Successful in 9m47s
SonarQube / build-front (push) Successful in 9m54s
SonarQube / build-back (push) Successful in 10m30s
Frontend / test (push) Failing after 5m3s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / test-back (push) Failing after 5m22s
SonarQube / SonarQube (push) Skipped
L'image frontend passe sur node:26-alpine3.22 ; la CI restait sur Node 24
et ne validait donc plus l'interpréteur qui construit le SPA. Angular 22.1.8
accepte >=26.0.0 ; Node 26 devient LTS le 28 octobre 2026, Node 24 passe en
maintenance le 20.
2026-09-22 08:42:46 +02:00
dependabot[bot]andJohan LEROY c347212c93 chore(deps): bump node in /apps/frontend
Bumps node from 24-alpine3.22 to 26-alpine3.22.

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 08:42:46 +02:00
Johan LEROY c67c7802c1 Merge pull request #131 from ineszang/dependabot/github_actions/actions/download-artifact-8
chore(deps): bump actions/download-artifact from 4 to 8
2026-09-22 08:42:06 +02:00
Johan LEROY e824423889 Merge pull request #128 from ineszang/dependabot/github_actions/actions/upload-artifact-7
chore(deps): bump actions/upload-artifact from 4 to 7
2026-09-22 08:42:03 +02:00
Johan LEROY 436147c8cb Merge pull request #132 from ineszang/dependabot/github_actions/actions/setup-node-7
chore(deps): bump actions/setup-node from 6 to 7
2026-09-22 08:42:00 +02:00
Johan LEROYandGitHub 8fb7ab8680 Merge pull request #130 from ineszang/dependabot/github_actions/astral-sh/setup-uv-7
chore(deps): bump astral-sh/setup-uv from 5 to 7
2026-09-22 08:40:33 +02:00
Johan LEROY 65baf3fddb docs(infra): aligne le README du proxy sur nginx 1.31-alpine
Le tag de l'image y est cité deux fois (présentation, commande nginx -t) ;
il suit celui de docker-compose.prod.yml. Le runner du Dockerfile frontend
est une image durcie dhi.io, suivie séparément, et n'est pas concerné.
2026-09-22 08:38:03 +02:00
dependabot[bot]andGitHub 19f4ddc62f chore(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:33:45 +00:00
Johan LEROYandGitHub 095cb4559b Merge pull request #142 from ineszang/chore/sonar-skip-dependabot
chore(ci): ignore le scan SonarQube pour les PR Dependabot
2026-09-22 08:31:48 +02:00
Johan LEROY d48b0eef4d chore(deps): ignore les majeures TypeScript et vitest côté Dependabot
Tant qu'Angular épingle typescript (>=6.0 <6.1) et vitest (^4), une majeure
de l'un ou l'autre casse npm ci. Dependabot arrête de les proposer ; à lever
quand Angular relâchera ces bornes.
2026-09-22 08:31:16 +02:00
Johan LEROY c8e8a44390 fix(frontend): garde vitest 4 et régénère le verrou pour TypeScript 6
@angular/build 22.1.8 exige typescript >=6.0 <6.1 et vitest ^4.0.8 : le
groupe Dependabot proposait TypeScript 7.0.2 et vitest 5.0.1, et npm ci
sortait en ERESOLVE. Le commit précédent avait ramené TypeScript à ~6.0.2
sans régénérer le verrou, qui pointait encore 7.0.2.

Verrou régénéré avec npm 11.19.0 (packageManager). Conservés : Angular
22.1.7, jsdom 30.1.0, prettier 3.9.8, @vitest/coverage-v8 aligné sur vitest.
2026-09-22 08:31:15 +02:00
Johan LEROY 401d5a3174 chore(ci): ignore le scan SonarQube pour les PR Dependabot
SonarQube / build-back (push) Successful in 1m20s
SonarQube / test-ml (push) Failing after 1m11s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 59s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / SonarQube (push) Skipped
GitHub ne fournit pas les secrets du dépôt aux workflows déclenchés par
dependabot[bot] : SONAR_TOKEN arrive vide et le scan échoue sans rien
analyser, ce qui marque rouge toutes les PR de mise à jour de dépendances.
Les jobs de build et de tests du workflow restent joués sur ces PR.
2026-09-22 08:28:03 +02:00
Johan LEROYandGitHub 6a70443d52 Merge pull request #139 from ineszang/feat/deploy-rec-prod
feat(infra,ci): deux environnements rec et prod sur la VM ENI, déployés par un runner auto-hébergé
2026-09-22 08:27:08 +02:00
Johan LEROY ebf6476fb6 docs(infra): rappelle que data/raw n'est pas dans git au provisionnement
Le DAG `historical_import` arrivé par #138 lit `./data/raw`, monté en lecture seule dans
le scheduler. Le dossier est vide dans un clone : sans dépôt manuel des fichiers, le DAG
n'a rien à charger sur la VM.
2026-09-22 08:22:07 +02:00
Johan LEROY d2ca2644d2 Merge remote-tracking branch 'origin/dev' into feat/deploy-rec-prod
# Conflicts:
#	docs/architecture/00-vue-ensemble.md
2026-09-22 08:21:59 +02:00
Dorian 6d741e45fb fix(ci): corrige la cle matchstr de l'en-tete Authorization dans le scan DAST et retire le diagnostic socat 2026-09-21 16:59:13 +02:00
Dorian 1bec2c1376 fix(ci): diagnostique les 400 du scan DAST avec socat et echoue si toutes les reponses sont des 4xx 2026-09-21 16:49:44 +02:00
Dorian 00fab49d80 fix(ci): charge le contrat OpenAPI depuis un fichier dans le scan DAST et publie les journaux ZAP 2026-09-21 16:42:25 +02:00
Meryemel-ghamandGitHub 91a1bfda33 Merge pull request #138 from ineszang/feat/dag-historical-import
Orchestre l'import historique avec Airflow
2026-09-21 16:34:06 +02:00
Dorian de88c4f156 fix(ci): corrige les issues Sonar du scan DAST et fait echouer un scan qui n'importe pas le contrat 2026-09-21 16:26:29 +02:00
Dorian 67dcf506e9 ci(backend): ajoute un scan DAST OWASP ZAP de l'API avec un compte lecteur jetable* 2026-09-21 16:11:36 +02:00
ineszang44 8386e05f31 fix: déploiement pour le frontend 2026-09-21 16:07:43 +02:00
Meryemel-gham fe100653d0 fix(etl): traite les retours de revue du DAG historique
Airflow / Lint et intégrité des DAGs (push) Successful in 57s
Airflow / Construction de l'image (push) Successful in 2m52s
SonarQube / build-back (push) Successful in 1m17s
SonarQube / test-ml (push) Failing after 1m50s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 55s
SonarQube / test-front (push) Failing after 5m3s
SonarQube / SonarQube (push) Skipped
2026-09-21 16:05:55 +02:00
Johan LEROY bccf7ed774 fix(infra,ci): le déploiement migre la base, et la doc cesse de dire déployé
`make stack-up` enchaîne `alembic upgrade head` dans le conteneur backend. Rien ne
migrait la base sur le chemin de déploiement, et `/api/v1/health/ready`, qui ne teste
que la connexion et l'extension TimescaleDB, aurait laissé passer un déploiement vert
sur une base sans schéma applicatif.

deploy.yml borne le job à 30 minutes et sort les journaux du backend et du proxy quand
la sonde échoue. provision-host.sh rappelle la création du premier administrateur, et
la propriété de /srv/enervision sans laquelle le runner ne peut ni manipuler les clones
ni lire un `.env` en 600.

Les statuts de livraison continue repassent à `En cours` : le code est écrit, la machine
n'est pas provisionnée, le runner n'est pas enregistré, rien n'a été déployé. À basculer
sur `Fait` au premier déploiement vert. Décompte des jobs corrigé, 18 et non 17.

Refs #21, #22
2026-09-21 15:58:03 +02:00
ValentinDeFariaandGitHub 1502eea036 Update package.json 2026-09-21 15:56:46 +02:00
Johan LEROY bf2b66e4ad feat(dx): make dev démarre la base, Mailpit et Airflow puis peuple la base
`make dev` ne lançait que le backend et le frontend : la base, Mailpit et
Airflow restaient à démarrer à la main, et les tables `prediction`, `alert` et
`recommendation` vides laissaient les vues correspondantes sans rien à afficher.

- `services-up` démarre les conteneurs, `db-wait` attend la base.
- `db-ensure-airflow` crée la base de métadonnées Airflow quand le volume
  `pgdata` est antérieur à `db/init/120-airflow-database.sql` : `db/init` ne
  rejoue qu'à la première initialisation, et `airflow-init` bouclait dessus.
- `demo-data` renseigne prédictions, alertes et recommandations si elles
  manquent, en ancrant scoring et détection au 31/12/2024 (`DEMO_NOW`), fin du
  jeu historique, plutôt qu'à l'horloge réelle.
- `ml-score` accepte `NOW=`, les cibles hors conteneur reçoivent
  `ML_DATABASE_URL` dérivé du `.env`.
2026-09-21 15:42:25 +02:00
Johan LEROY 8ea388ce9a fix(backend): découpe l'insert des alertes en lots de 1000
`AlertRepository.create_many` envoyait un `INSERT` d'un seul tenant. À douze
colonnes par alerte, le plafond asyncpg de 32 767 paramètres tombe à 2 730
lignes : une détection sur une fenêtre chargée échouait en `InterfaceError`,
et le DAG `alertes` avec elle.

Reprend le patron déjà en place dans `RecommendationRepository.create_missing`.
2026-09-21 15:42:16 +02:00
Valentin 9cd4f0de1c Merge branch 'feat/entrainement-du-modele' of https://github.com/ineszang/ProjetPiscine_EnerVision into feat/registry-modele-prediction 2026-09-21 15:41:14 +02:00
Valentin 5cc99178c2 fix(ml): execute MLflow en non-root et installe uniquement des wheels 2026-09-21 15:41:02 +02:00
Johan LEROYandGitHub b4da0bbefc Merge pull request #137 from ineszang/feat/vue-recommandations
feat(frontend): vue recommandations liée à une alerte ou au site consulté
2026-09-21 15:23:14 +02:00
Johan LEROYandGitHub 25e0bb4ce1 Merge pull request #136 from ineszang/feat/widget-alertes
feat(frontend): flux d'alertes filtrable par site et sévérité, refonte du tableau de bord
2026-09-21 15:22:42 +02:00
ValentinDeFariaandGitHub b41a16364b Merge branch 'dev' into feat/entrainement-du-modele 2026-09-21 15:14:57 +02:00
Johan LEROYandClaude Opus 5 ce3d1d1992 fix(frontend): rend .ev-table-card plus spécifique que le padding de ev-card
Frontend / Audit des dépendances (push) Successful in 5s
SonarQube / build-back (push) Successful in 1m6s
SonarQube / test-ml (push) Failing after 1m10s
SonarQube / build-front (push) Successful in 9m37s
Frontend / build (push) Successful in 23m57s
SonarQube / test-back (push) Failing after 49s
SonarQube / test-front (push) Failing after 5m22s
SonarQube / SonarQube (push) Skipped
Frontend / test (push) Failing after 5m3s
`.ev-table-card` et le `:host` de `ev-card`, compilé en `[_nghost-…]`, ont la
même spécificité. Les styles de composant sont injectés dans le head après la
feuille globale : le padding de la carte gagnait, et le tableau de la liste des
sites comme celui des prévisions perdaient leur mise à plat. Le sélecteur
d'élément `ev-card.ev-table-card` tranche, comme `_auth-page.scss` le fait déjà.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:10:01 +02:00
Valentin 33aeea835b fix(ml): retire le mot de passe PostgreSQL du compose 2026-09-21 15:06:05 +02:00
Johan LEROY cb23895026 feat(infra,ci): deux environnements rec et prod sur la VM ENI, déployés par un runner auto-hébergé
Un projet Compose par environnement sur la même machine : ports du proxy et origine
publique en variables dans docker-compose.prod.yml, réglage mémoire des deux bases
TimescaleDB et du webserver Airflow. Le workflow deploy.yml déploie dev en recette et
main en production depuis un runner installé sur la VM, jamais sur pull_request.
scripts/provision-host.sh prépare les deux dossiers, secrets et certificats compris,
sans rien démarrer.

L'image frontend quitte dhi.io/nginx, registre authentifié dont personne n'a l'accès,
pour nginx:1.28-alpine : elle n'avait jamais été construite.

ADR 0009, vues infra et CI/CD, README et .env.example mis à jour.

Refs #21, #22
2026-09-21 14:59:32 +02:00
Valentin 9312d3b60f feat(ml): enregistrer le modèle dans le MLflow Model Registry 2026-09-21 14:52:00 +02:00
Johan LEROY 2c86e00629 style(frontend): ramène deux commentaires du dashboard à deux lignes 2026-09-21 14:44:23 +02:00
PhyriosandGitHub cf50d3d36c Revise project milestones in README
Updated project milestones in the README to reflect new tasks and structure.
2026-09-21 14:42:41 +02:00
Johan LEROY 7e504bee72 docs(frontend): documente la vue recommandations
Route, paramètres ?site= et ?alert=, jointure côté client et sa raison (pas de
site_id sur une recommandation, aucun filtre sur GET /recommendations,
/alerts non paginé), génération réservée aux admins, points d'entrée.
2026-09-21 14:41:56 +02:00
Johan LEROY 32155cbfc5 feat(frontend): section recommandations sur la vue détail d'un site
La vue détail pose app-recommendation-list restreint au site consulté (les
alertes sont demandées avec site_id) et renvoie vers la vue complète préfiltrée
sur ce site. Le spec fournit les deux services du composant enfant et vérifie le
filtre transmis.
2026-09-21 14:41:56 +02:00
Meryemel-gham cb4df846cb docs(etl): documente l'orchestration de l'import historique 2026-09-21 14:41:38 +02:00
Johan LEROY d16de84559 feat(frontend): vue /recommendations avec filtre site, focus ?alert= et génération admin
Page derrière authGuard, ouverte à tous les rôles : filtre site (.form-select,
présélectionné par ?site=), focus sur une alerte par ?alert= (entier strictement
positif, sinon ignoré), et pour les admins un bouton « Générer les
recommandations » qui appelle POST /recommendations/generate sur le site filtré,
affiche le bilan accordé en nombre et recharge la liste. Route ajoutée dans
app.routes.ts, lien « Recommandations » dans la navigation du tableau de bord.
2026-09-21 14:41:33 +02:00
Meryemel-gham ae4082c584 ci(etl): valide l'import historique dans l'image Airflow 2026-09-21 14:41:26 +02:00
Meryemel-gham f18d4f9ef9 test(etl): couvre le DAG d'import historique 2026-09-21 14:41:08 +02:00
Meryemel-gham 308769b325 feat(etl): orchestre l'import historique avec Airflow 2026-09-21 14:40:21 +02:00
Johan LEROY 90639b5618 feat(frontend): composant app-recommendation-list, jointure alertes/recommandations
Une recommandation ne porte que alert_id et GET /recommendations n'a aucun
filtre : le composant charge en parallèle les alertes (filtrées par site quand
`siteId` est fourni) et toutes les recommandations, puis les joint côté client
(joinByAlert, fonction pure testée à part) en groupes par alerte, du plus récent
au plus ancien. Chaque groupe montre le contexte de l'alerte (sévérité, type,
site, horodatage, message) puis ses actions avec l'explication et la règle.
L'input `alertId` réduit la vue à une alerte et la met en évidence ; `reload()`
rejoue les deux appels. Un échec de l'un des deux vide tout : une demi-jointure
tromperait.
2026-09-21 14:39:56 +02:00
Johan LEROY e147ea69bc feat(frontend): modèle et service Recommendations (liste, détail, génération)
Types alignés sur RecommendationResponse et RecommendationGenerationResponse
du backend. RecommendationsService couvre GET /recommendations,
GET /recommendations/{id} et POST /recommendations/generate?site_id= (réservé
admin côté API). recommendation-presentation.ts traduit les sept règles connues
du moteur et retombe sur la référence brute pour une règle inconnue, la
politique de renommage en -v2 de l'ADR 0006 l'impose.
2026-09-21 14:39:56 +02:00
Johan LEROY f0ff953a5e docs(frontend): met à jour l'état du frontend et le design système
30-frontend.md : le mode fixtures est inactif dans les deux environnements
(la doc affirmait l'inverse), services et pages manquants, widget alertes et
états de chargement, strict activé. 32-design-systeme-frontend.md : tokens
typographiques, ev-icon, app-alert-feed, classes de tableau.
2026-09-21 14:35:30 +02:00
Johan LEROY 7fd8d1ce30 fix(frontend): restaure useMockFixtures à sa valeur d'origine après chaque test
L'afterEach forçait le drapeau à true alors qu'il vaut false dans les deux
environnements : avec isolate désactivé, tout spec joué ensuite sur
/stats/summary ou /alerts aurait reçu une fixture au lieu d'atteindre
HttpTestingController.
2026-09-21 14:35:30 +02:00
Johan LEROY 6785d06a9a refactor(frontend): modernise la mise en page du tableau de bord
- ligne d'état « Actualisé à HH:mm:ss · N sites suivis » avec un point animé,
  figé sous prefers-reduced-motion
- indicateurs en cartes : libellé en capitales, grand nombre en chiffres
  tabulaires, filet supérieur, ombre au survol ; la barre de charge moyenne
  passe au jaune à 70 % et au rouge à 90 %, avec un texte d'aide
- grille à deux colonnes : graphique de charge et prévisions à gauche, flux
  d'alertes en colonne collante à droite, une colonne sous 900 px
- prévisions présentées en tableau (site, prévision, échéance, lien détail)
- liens de navigation de l'en-tête restylés en pastilles, par SCSS seulement :
  le bloc HTML de navigation est inchangé octet pour octet
- styles de tableau sortis dans _tables.scss (.ev-table, .ev-table-card), la
  liste des sites les adopte au lieu de sa copie locale
2026-09-21 14:35:30 +02:00
Johan LEROY c2bd1317ed refactor(frontend): remplace la section alertes du dashboard par app-alert-feed
Le dashboard ne charge plus les alertes lui-même : le widget porte le flux, ses
filtres, ses états et son rafraîchissement. Disparaissent la liste rouge quelle
que soit la sévérité, le bandeau d'erreur dédié et la copie locale de la table
sévérité vers ton, désormais dans alert-presentation.ts. Le spec passe par un
helper setup() qui fournit aussi les services du widget enfant.
2026-09-21 14:32:07 +02:00
Johan LEROY d276070ee2 feat(frontend): widget app-alert-feed filtrable, rafraîchi toutes les 60 s
Flux des alertes actives sur GET /alerts : filtres site et sévérité posés en
paramètres de requête (l'API les accepte), couleur et badge par sévérité, icône
par type, nom du site résolu depuis GET /sites, mesure et seuil avec leur unité.
Rafraîchissement par timer(0, 60 s) relancé à chaque changement de filtre, états
chargement / vide / indisponible, pagination côté client par dix car l'API ne
pagine pas. L'input `siteId` fige le site et masque son filtre, pour la vue
détail d'un site.
2026-09-21 14:29:22 +02:00
Johan LEROY e10ab44dc7 feat(frontend): composant ev-icon en SVG inline pour les types d'alerte
Cinq tracés (spike, threshold, anomaly, outage, sensor) dessinés en trait sur
currentColor, dimensionnés par font-size comme ev-brand. Décoratif par défaut
(aria-hidden), rôle image et libellé accessible quand `label` est fourni.
Aucune bibliothèque : la CSP du reverse proxy interdit les scripts tiers, pas le
SVG inline.
2026-09-21 14:29:22 +02:00
Johan LEROY 32dd0587e9 feat(frontend): ajoute l'échelle typographique et l'ombre de survol aux tokens
Six tailles de police (--font-size-xs à --font-size-2xl) et --shadow-card-hover,
pour que les nouveaux composants cessent de poser des rem en dur.
2026-09-21 14:29:22 +02:00
PhyriosandGitHub 4afcfb3ced Update project status in README.md 2026-09-21 14:28:56 +02:00
Johan LEROY 83caa9006e chore(frontend): active strict, ajoute .form-select et documente le spec ciblé
Frontend / Audit des dépendances (push) Successful in 5s
SonarQube / build-back (push) Successful in 1m6s
Frontend / build (push) Successful in 9m47s
SonarQube / test-ml (push) Failing after 2m2s
SonarQube / build-front (push) Successful in 10m5s
SonarQube / test-back (push) Failing after 49s
Frontend / test (push) Failing after 5m5s
SonarQube / test-front (push) Failing after 5m3s
SonarQube / SonarQube (push) Skipped
- tsconfig.json : "strict": true, vérifié sans erreur sur app et specs
- _forms.scss : .form-select, select natif habillé comme .form-input avec un
  chevron, documenté dans le design système
- TESTING.md : commande pour jouer un seul fichier ou dossier de specs
2026-09-21 14:25:30 +02:00
Johan LEROY 91752a2b0b fix(frontend): aligne le modèle Alert et AlertsService sur le contrat /alerts
Le modèle front portait un alert_id texte, des value/threshold non nullables et
ignorait metric et prediction_id, alors que l'API sérialise un entier, des
flottants nullables et ces deux champs. Toute comparaison avec l'alert_id d'une
recommandation échouait silencieusement.

- alert.model.ts : alert_id number, value/threshold/metric/prediction_id nullables
- alerts.service.ts : getAlerts(filters) pose site_id et severity en HttpParams,
  les deux filtres que l'API accepte
- alerts.fixture.ts : réaligné sur le contrat (ids entiers, horodatages UTC,
  champs nuls sur outage/sensor, un cas spike)
- alert-presentation.ts : tons, libellés et unités partagés ; low passe en
  neutre, le vert se lisait comme un état sain
2026-09-21 14:25:30 +02:00
dependabot[bot]andGitHub 79b157d37e chore(deps): bump the frontend-dependencies group
Bumps the frontend-dependencies group in /apps/frontend with 12 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `22.1.6` | `22.1.7` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `22.1.6` | `22.1.7` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `22.1.6` | `22.1.7` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `22.1.6` | `22.1.7` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `22.1.6` | `22.1.7` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `22.1.6` | `22.1.7` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `22.1.6` | `22.1.7` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.11` | `5.0.1` |
| [jsdom](https://github.com/jsdom/jsdom) | `28.1.0` | `30.1.0` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.3` | `7.0.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.11` | `5.0.1` |


Updates `@angular/common` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/common)

Updates `@angular/compiler` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/compiler)

Updates `@angular/core` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/core)

Updates `@angular/forms` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/forms)

Updates `@angular/platform-browser` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/platform-browser)

Updates `@angular/router` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/router)

Updates `@angular/compiler-cli` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/compiler-cli)

Updates `@vitest/coverage-v8` from 4.1.11 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/coverage-v8)

Updates `jsdom` from 28.1.0 to 30.1.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v28.1.0...v30.1.0)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2)

Updates `vitest` from 4.1.11 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/vitest)

---
updated-dependencies:
- dependency-name: "@angular/common"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/core"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/router"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 22.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: jsdom
  dependency-version: 30.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: vitest
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:19:44 +00:00
dependabot[bot]andGitHub 851e0279f6 chore(deps): bump the backend-dependencies group
Bumps the backend-dependencies group in /apps/backend with 2 updates: [pandas](https://github.com/pandas-dev/pandas) and [ruff](https://github.com/astral-sh/ruff).


Updates `pandas` from 3.0.5 to 3.0.6
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v3.0.5...v3.0.6)

Updates `ruff` from 0.16.7 to 0.16.8
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.7...0.16.8)

---
updated-dependencies:
- dependency-name: pandas
  dependency-version: 3.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-dependencies
- dependency-name: ruff
  dependency-version: 0.16.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: backend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:19:00 +00:00
dependabot[bot]andGitHub 7f025f72ac chore(deps): bump nginx from 1.28-alpine to 1.31-alpine
Bumps nginx from 1.28-alpine to 1.31-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:17:47 +00:00
PhyriosandGitHub 1cd206adc8 Merge pull request #125 from ineszang/dev
Push dev to main
2026-09-21 14:17:01 +02:00
Dorian 3c378c177f ci(ml,etl): analyse ml/ et etl/airflow dans SonarCloud avec un rapport de couverture ML 2026-09-21 14:12:38 +02:00
Dorian 2adfdf0eb0 fix(backend): supprime les vulnerabilites Sonar du Dockerfile et allege les tests d'exception 2026-09-21 14:08:04 +02:00
ineszang44 2a7e1c25fc Merge branch 'dev' into feat/deploy 2026-09-21 14:06:42 +02:00
ineszang44 5d955ca991 fix: suppression du flag "--mount" 2026-09-21 14:05:55 +02:00
PhyriosandGitHub 44f3416ffe Merge branch 'main' into dev 2026-09-21 13:48:50 +02:00
Johan LEROYandGitHub 342128ccff Merge pull request #121 from ineszang/docs/livrables-ec03-ec06
docs(architecture,ml): vue CI/CD, ML-START.md et SAST Bandit
2026-09-21 13:45:46 +02:00
Johan LEROYandClaude Opus 5 62d81e901d fix(ci,docs): lève les points de revue du SAST et de la vue CI/CD
Backend / Tests exigeant une base (push) Failing after 34s
Backend / Lint, typage et tests (push) Successful in 1m43s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Audit des dépendances (push) Successful in 57s
ML / Analyse statique de sécurité (push) Successful in 7s
ML / Lint, typage et tests (push) Successful in 3m0s
ML-START.md affirmait que l'orchestration Airflow n'existait pas : `ml_score`
tourne en `@hourly` depuis l'issue #115, seuls le mode `--csv` et un lancement
local restent manuels.

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

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

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

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

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

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

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

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

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

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

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

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

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

Enfin `certbot/certbot` est épinglé en v5.8.0 pour que Dependabot puisse le suivre, le
proxy attend une API saine plutôt que démarrée, et la redirection vers `$host` est actée
comme risque accepté : figer un nom canonique couperait l'accès par adresse IP, seule
voie ouverte sur la machine cible.
2026-09-21 12:11:32 +02:00
Valentin 268496a8c4 feat(ml): enregistrer le modèle dans le MLflow Model Registry 2026-09-21 12:04:49 +02:00
Johan LEROY d7457e9fd9 Merge remote-tracking branch 'origin/dev' into docs/livrables-ec03-ec06
# Conflicts:
#	docs/architecture/00-vue-ensemble.md
2026-09-21 11:56:08 +02:00
ineszang44 cefb07067d init: fichier terraform pour le backend 2026-09-21 11:49:10 +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
ineszang44 3cc6c67e0d Merge branch 'dev' into feat/deploy 2026-09-21 11:05:12 +02:00
Johan LEROY 5545c166fd docs(architecture): ajoute la vue CI/CD et corrige trois affirmations fausses
La documentation du pipeline est explicitement notée par EC03 (C20) et n'existait
pas. L'index des vues justifiait son absence par un manque de matière : quatre
workflows et quatorze jobs en sont assez.

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

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

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

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

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

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

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

Ferme C34 de la grille d'auto-évaluation.
2026-09-21 10:50:12 +02:00
ineszang44 93afc6031c fix: lancement de la commande npm run build, source directe pour l'image du runner 2026-09-21 10:40:14 +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
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
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
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
ineszang44 99db807e3c feat: création auto de l'image et du conteneur 2026-09-18 15:41:54 +02:00
Valentin 7f710c9084 feat(frontend): supervision des capteurs par site (admin) 2026-09-18 12:02:48 +02:00
ineszang44 47c4fd445b init: terraform 2026-09-18 09:48:20 +02:00
ineszang44 667592033b chore(frontend): suppression des variables inutiles, copie des fichiers terraform communs aux environnements 2026-09-17 15:01:17 +02:00
ineszang44 6785c70f6f Merge remote-tracking branch 'origin/dev' into feat/deploy 2026-09-17 14:51:42 +02:00
ineszang44 692ec436a5 feat+chore(frontend): déploiement selon l'env sélectionné, commentaires sur le fichier terraform 2026-09-17 11:28:22 +02:00
ineszang44 a3d32a6fb1 chore(frontend): fichier tf pour le front 2026-09-16 16:24:59 +02:00
PhyriosandGitHub c3fd9327ea Definition des jalons 2026-09-14 11:30:01 +02:00
213 changed files with 16416 additions and 2243 deletions
+51
View File
@@ -17,9 +17,60 @@ APP_LOG_LEVEL=INFO
APP_SECRET_KEY=change_me APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200 APP_CORS_ORIGINS=http://localhost:4200
BACKEND_PORT=8000 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 # API Mock EnerVision
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
APP_MOCK_API_USERNAME=change_me APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10 APP_MOCK_API_TIMEOUT_SECONDS=10
# Airflow (api-server + scheduler + dag-processor, 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é de session de l'api-server Airflow, distincte de la précédente. Générer la
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_API_SECRET_KEY=change_me
# Secret des jetons JWT entre scheduler, tâches et api-server (conteneurs distincts, le secret
# doit être partagé). Même générateur que ci-dessus.
AIRFLOW_JWT_SECRET=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=
# Deux environnements sur la même machine (ADR 0009) : un dossier, un `.env` et un projet Compose
# chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`.
# Vide sur un poste de développement : le projet reste `enervision`.
COMPOSE_PROJECT_NAME=
# Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST.
# Recette : PUBLIC_HOST=rec.enervision.local et PUBLIC_ORIGIN=https://rec.enervision.local:8443.
PUBLIC_ORIGIN=
# Ports publiés par le proxy. Vides : 80 et 443. Recette : PROXY_HTTPS_PORT=8443 et
# PROXY_HTTP_PORT=127.0.0.1:8081, la redirection vers 443 n'ayant pas à être joignable de
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
PROXY_HTTP_PORT=
PROXY_HTTPS_PORT=
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
# machine à chaque base au premier démarrage. L'api-server Airflow 3 n'a rien à régler ici : son
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
TS_TUNE_MEMORY=2GB
TS_TUNE_NUM_CPUS=2
+15
View File
@@ -10,6 +10,15 @@ updates:
frontend-dependencies: frontend-dependencies:
patterns: patterns:
- "*" - "*"
ignore:
# Pourquoi : @angular/build épingle typescript (>=6.0 <6.1) et vitest (^4). Une majeure
# de l'un ou l'autre casse `npm ci` tant qu'Angular ne suit pas.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
- dependency-name: "vitest"
update-types: ["version-update:semver-major"]
- dependency-name: "@vitest/coverage-v8"
update-types: ["version-update:semver-major"]
# Backend — uv (lit pyproject.toml / uv.lock) # Backend — uv (lit pyproject.toml / uv.lock)
- package-ecosystem: "uv" - package-ecosystem: "uv"
@@ -38,3 +47,9 @@ updates:
directory: "/apps/frontend" directory: "/apps/frontend"
schedule: schedule:
interval: "weekly" interval: "weekly"
# Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose
- package-ecosystem: "docker-compose"
directory: "/"
schedule:
interval: "weekly"
+104
View File
@@ -0,0 +1,104 @@
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) : celui de l'image apache/airflow retenue, et les tests
# doivent tourner sur le même interpréteur qu'elle. Le 3.14 de ml/ ne vit que dans l'image
# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
#
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API.
on:
push:
paths:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
pull_request:
paths:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
permissions:
contents: read
concurrency:
group: airflow-${{ github.ref }}
cancel-in-progress: true
jobs:
verification:
name: Lint et intégrité des DAGs
runs-on: ubuntu-latest
defaults:
run:
working-directory: etl/airflow
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
- name: Vérifie le formatage
run: uv run ruff format --check .
- name: Analyse statique
run: uv run ruff check --output-format=github .
# Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et
# vérifie import, planification, plafonds d'exécution et commande de chaque tâche.
- name: Tests d'intégrité des DAGs
run: uv run pytest
image:
name: Construction de l'image
runs-on: ubuntu-latest
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
# Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent
# (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé.
- name: Vérifie que le pipeline ML s'importe sans réseau
run: >
docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
# l'import des modules prouve que l'environnement /opt/backend est complet.
# Les commandes des DAGs `alertes`, historique et API Mock sont couvertes.
- name: Vérifie que les quatre commandes backend 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
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help"
+32 -6
View File
@@ -30,10 +30,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -90,10 +90,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -126,10 +126,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -140,3 +140,29 @@ jobs:
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe. # Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
shell: bash shell: bash
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
sast:
name: Analyse statique de sécurité
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@v7
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
- name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium
# Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace.
- name: Rapport complet, tous niveaux
continue-on-error: true
run: uvx bandit==1.9.4 --recursive app
+325
View File
@@ -0,0 +1,325 @@
name: DAST
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
# la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne
# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
#
# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
# scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit
# vouloir dire qu'un scan a eu lieu.
#
# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui.
on:
workflow_dispatch:
schedule:
# Un scan actif est long : hebdomadaire plutôt qu'à chaque PR.
- cron: "0 3 * * 1"
pull_request:
# Ne se lance sur une PR que si le scan lui-même change.
paths:
- ".github/workflows/dast.yml"
- "scripts/dast-token.sh"
permissions:
contents: read
concurrency:
group: dast-${{ github.ref }}
cancel-in-progress: true
jobs:
zap:
name: Scan OWASP ZAP de l'API
runs-on: ubuntu-latest
# Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est
# `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par
# ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre.
timeout-minutes: 30
# Même image que docker-compose.yml : la première migration refuse de s'appliquer sans
# l'extension TimescaleDB (cf. backend.yml).
services:
db:
image: timescale/timescaledb-ha:pg17
env:
POSTGRES_USER: enervision
POSTGRES_PASSWORD: change_me
POSTGRES_DB: enervision_dast
ports:
- "5433:5432"
options: >-
--health-cmd "pg_isready -U enervision -d enervision_dast"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 40s
env:
# Base jetable : ZAP y écrira et le script y crée deux comptes.
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
APP_ENV: local
# Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut).
# 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins`
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
# `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
# « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
# run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
# qu'une optimisation de taille de cache entre deux runs, pas une garantie : le
# désactiver retire le blocage sans rien changer au comportement du job.
prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: apps/backend
# `--no-build` : aucune dépendance n'est construite depuis ses sources, donc aucun script de
# build exécuté (règle Sonar S8541). Le projet lui-même n'est pas installé : il tourne depuis
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --frozen --no-dev --no-install-project --no-build
working-directory: apps/backend
- name: Active TimescaleDB sur la base du scan
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
- name: Applique les migrations
run: uv run --frozen --no-sync --no-build alembic upgrade head
working-directory: apps/backend
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
# frappe que des gestionnaires d'erreur plutôt que la logique métier. `db/seeds/` est vide
# (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
# juste assez pour que les routes de lecture aient quelque chose à rendre.
- name: Insère un site et des relevés minimaux pour le scan
run: |
psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL'
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif')
ON CONFLICT (site_id) DO NOTHING;
INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data)
VALUES
('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'),
('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}')
ON CONFLICT DO NOTHING;
SQL
- name: Démarre l'API
run: |
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
--host 0.0.0.0 --port 8000 > "$RUNNER_TEMP/api.log" 2>&1 &
for _ in $(seq 1 30); do
curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0
sleep 2
done
echo "L'API ne répond pas sur /health/ready" >&2
cat "$RUNNER_TEMP/api.log" >&2
exit 1
working-directory: apps/backend
- name: Crée le compte lecteur du scan
id: jeton
run: |
jeton="$(../../scripts/dast-token.sh)"
echo "::add-mask::$jeton"
echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
working-directory: apps/backend
# Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
# (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
# pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante.
- name: Prépare le contrat pour ZAP
run: |
mkdir -p zap-out zap-logs
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
# Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit
# plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous.
# Pas de `chmod 777` (règle Sonar S2612).
sudo chown -R 1000:1000 zap-out
# `--network host` : ZAP atteint l'API sur le localhost du runner.
#
# Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
# n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
# requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
# (« Invalid HTTP request received »), y compris sur les routes publiques.
#
# Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de
# commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par
# l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le
# conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en
# lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié.
#
# Les routes d'authentification qui changent l'état du compte du scan sont exclues : un
# scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de
# passe et la fermeture des sessions, sans rien apprendre de plus.
#
# `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
# couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui
# traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui
# annule sans exécuter les étapes `always()`, rapport et journaux perdus).
- name: Scan ZAP
id: zap
continue-on-error: true
env:
JETON: ${{ steps.jeton.outputs.jeton }}
run: |
set -o pipefail
printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf"
# Piège vécu : `chmod 600` seul rend le fichier illisible pour le conteneur, qui lit un
# montage bind avec son propre uid (1000), distinct de celui du runner qui l'a écrit.
# ZAP échoue alors dès le lancement (« File not readable: /zap/auth.conf »), et
# `zap-api-scan.py` attend `-T` minutes complètes avant d'abandonner : dix minutes qui
# ressemblent à un scan actif, pour un daemon mort depuis le début.
#
# Piège vécu (numéro deux) : une fois le fichier passé à l'uid 1000 par `sudo chown`,
# l'utilisateur du runner n'en est plus propriétaire et un `chmod` sans `sudo` échoue
# (« Operation not permitted »). Avec le `-e` implicite de bash sur les étapes GitHub
# Actions, cette erreur arrêtait toute l'étape avant même `docker run` : scan « réussi »
# en une fraction de seconde, sans le moindre journal ni rapport produit.
sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf"
sudo chmod 644 "$RUNNER_TEMP/zap-auth.conf"
docker run --name zap --network host \
-v "$PWD/zap-out:/zap/wrk:rw" \
-v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
-T 10 \
-r zap-report.html -J zap-report.json -w zap-report.md \
-z "-configfile /zap/auth.conf \
-config globalexcludeurl.url_list.url(0).description=auth-etat \
-config globalexcludeurl.url_list.url(0).enabled=true \
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \
-config scanner.maxScanDurationInMins=15 \
-config scanner.maxRuleDurationInMins=5" \
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
- name: Récupère les journaux de ZAP
if: always()
run: |
mkdir -p zap-logs
# ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
# à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
# même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job.
masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; }
[ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log
docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true
[ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log
[ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log
rm -f "$RUNNER_TEMP/zap-auth.conf"
docker rm -f zap >/dev/null 2>&1 || true
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
# testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
# que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
# : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était.
- name: Vérifie que le contrat a bien été importé
run: |
attendu="$(python3 -c "
import json
d = json.load(open('zap-out/openapi.json'))
methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes))
")"
minimum=$((attendu * 80 / 100))
importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)"
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
if [ "${importees:-0}" -lt "$minimum" ]; then
echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
exit 1
fi
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
# (constaté : base sans données, toutes les routes de site répondaient 404).
#
# Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le
# pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre
# d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan
# sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx :
# c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue
# vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier
# incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès,
# pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt
# que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus
# d'être fragile au format.
- name: Vérifie que le scan a obtenu au moins une réponse de succès
run: |
python3 - <<'PY'
import json
import sys
try:
rapport = json.load(open("zap-out/zap-report.json"))
except FileNotFoundError:
print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.")
sys.exit(1)
pourcentage_2xx = 0.0
for insight in rapport.get("insights", []):
if insight.get("key") == "insight.code.2xx":
pourcentage_2xx = float(insight.get("statistic", 0))
break
print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%")
if pourcentage_2xx <= 0:
print(
"::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route "
"réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact "
"zap-report."
)
sys.exit(1)
PY
# Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est
# limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job
# après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport
# complet reste dans l'artefact `zap-report`.
- name: Publie le résumé
if: always()
run: |
if [ -f zap-out/zap-report.md ]; then
awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY"
else
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Publie les rapports
if: always()
uses: actions/upload-artifact@v7
with:
name: zap-report
path: |
zap-out/
zap-logs/
if-no-files-found: warn
# Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
# (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les
# afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec
# évident (l'API n'a jamais démarré, par exemple).
- name: Journal de l'API en cas d'échec
if: failure() || steps.zap.outcome == 'failure'
run: cat "$RUNNER_TEMP/api.log" || true
+58
View File
@@ -0,0 +1,58 @@
# Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub
# ne joignent pas, et travaille dans un dossier stable par environnement plutôt que dans son
# espace de travail : `.env`, certificats et volumes y survivent d'un déploiement à l'autre.
# Piège : jamais de déclencheur `pull_request` ici. Sur un dépôt public, une PR de fork
# exécuterait son code sur la machine de production (ADR 0009) - job deploy.
name: Déploiement
on:
push:
branches: [dev, main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, linux, eni-g3]
timeout-minutes: 30
environment:
name: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || 'https://rec.enervision.local:8443' }}
env:
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || '8443' }}
steps:
- name: Aligner le dossier de l'environnement sur la branche poussée
run: |
cd "/srv/enervision/${ENVIRONNEMENT}"
git fetch --quiet origin "${GITHUB_REF_NAME}"
git checkout --quiet "${GITHUB_REF_NAME}"
git reset --quiet --hard "origin/${GITHUB_REF_NAME}"
git log -1 --format='%h %s'
- name: Reconstruire et redémarrer la stack
run: |
cd "/srv/enervision/${ENVIRONNEMENT}"
make stack-up
- name: Attendre que l'API réponde derrière le proxy
run: |
for tentative in $(seq 1 36); do
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
exit 0
fi
sleep 5
done
echo "L'API ne répond pas après 3 minutes" >&2
cd "/srv/enervision/${ENVIRONNEMENT}"
compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
$compose ps
$compose logs --tail=50 backend proxy
exit 1
+10 -10
View File
@@ -17,10 +17,10 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -33,10 +33,10 @@ jobs:
name: Audit des dépendances name: Audit des dépendances
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison. # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only - run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend working-directory: apps/frontend
@@ -45,10 +45,10 @@ jobs:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front) - name : Installation des dépendances (Front)
@@ -58,7 +58,7 @@ jobs:
run: npm test --watch=false --code-coverage --coverageReporters=lcov run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend working-directory: apps/frontend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info path: apps/frontend/coverage/frontend/lcov.info
+52
View File
@@ -0,0 +1,52 @@
name: Infra
# Pourquoi : le Terraform du dépôt est resté cassé sans que rien ne le dise, faute de job qui le
# joue. Ce workflow n'applique rien : il vérifie le formatage et la validité de chaque racine.
# Piège : la boucle parcourt `environments/*`, pour qu'une racine ajoutée soit couverte sans
# toucher à ce fichier.
on:
push:
paths:
- "infra/terraform/**"
- ".github/workflows/infra.yml"
pull_request:
paths:
- "infra/terraform/**"
- ".github/workflows/infra.yml"
permissions:
contents: read
concurrency:
group: infra-${{ github.ref }}
cancel-in-progress: true
jobs:
terraform:
name: Formatage et validation Terraform
runs-on: ubuntu-latest
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Action tierce, donc epinglee sur un SHA de commit et pas sur un tag mobile : un tag se
# redeplace, et ce workflow tourne avec les droits du depot (regle Sonar githubactions:S7637).
- name: Installe Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.16.3
terraform_wrapper: false
- name: Vérifie le formatage
run: terraform fmt -check -recursive infra/terraform
- name: Valide chaque racine
run: |
for racine in infra/terraform/environments/*/; do
echo "::group::${racine}"
terraform -chdir="${racine}" init -backend=false -input=false
terraform -chdir="${racine}" validate
echo "::endgroup::"
done
+125 -4
View File
@@ -8,10 +8,28 @@ on:
paths: paths:
- "ml/**" - "ml/**"
- ".github/workflows/ml.yml" - ".github/workflows/ml.yml"
# Le job `integration` monte son schema avec les migrations du backend et joue le test de
# chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait
# rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement
# que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
- "apps/backend/alembic/**"
- "apps/backend/app/models/**"
- "apps/backend/tests/test_chaine_ml_api.py"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
pull_request: pull_request:
paths: paths:
- "ml/**" - "ml/**"
- ".github/workflows/ml.yml" - ".github/workflows/ml.yml"
# Le job `integration` monte son schema avec les migrations du backend et joue le test de
# chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait
# rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement
# que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
- "apps/backend/alembic/**"
- "apps/backend/app/models/**"
- "apps/backend/tests/test_chaine_ml_api.py"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
permissions: permissions:
contents: read contents: read
@@ -30,10 +48,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: ml/uv.lock cache-dependency-glob: ml/uv.lock
@@ -53,7 +71,110 @@ jobs:
- name: Typage - name: Typage
run: uv run mypy enervision_ml tests run: uv run mypy enervision_ml tests
# Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees # Les tests exigeant une base portent le marqueur `integration`, ecarte par defaut et
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). # joue par le job `integration` ci-dessous.
- name: Tests - name: Tests
run: uv run pytest run: uv run pytest
# Le seul job du depot qui dispose a la fois des deux environnements uv et d'une base. Piege :
# le schema de la base ML est celui du backend (apps/backend/alembic, proprietaire du schema).
# Le reconstruire ici a la main rendrait ce job vert sur une base qui n'est pas la notre.
integration:
name: ML - DB et chaîne ML - DB - API
runs-on: ubuntu-latest
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:
# Deux variables, deux dialectes : Alembic et l'API parlent asyncpg, le pipeline ML parle
# psycopg en synchrone. Cf. docs/ML-START.md, section 1.
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test
ML_DATABASE_URL: postgresql+psycopg://enervision:change_me@localhost:5433/enervision_test
APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: |
ml/uv.lock
apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
working-directory: ml
run: uv python install
- name: Synchronise le pipeline ML sans dévier du verrou
working-directory: ml
run: uv sync --all-groups --frozen
# Le backend est installé ici parce qu'il porte les migrations, seule source du schéma, et
# le test de chaîne, qui interroge l'API.
- name: Synchronise le backend sans dévier du verrou
working-directory: apps/backend
run: uv sync --all-groups --frozen
# db/init/110-test-database.sql n'est pas monté ici, et sans l'extension la première
# révision Alembic refuse de s'appliquer.
- 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 du backend, propriétaire du schéma
working-directory: apps/backend
run: uv run alembic upgrade head
# `-m` en ligne de commande écrase celui d'addopts. Couverture désactivée : ce job ne joue
# qu'une partie de la suite, son taux n'aurait pas de sens (même raison que backend.yml).
- name: Tests ML exigeant une base
working-directory: ml
run: uv run pytest -m integration --no-cov
# Lance les vrais binaires enervision_ml.train et .score en sous-processus, comme les DAGs
# ml_train et ml_score, puis relit le résultat par GET /api/v1/predictions.
- name: Chaîne complète ML vers DB vers API
working-directory: apps/backend
env:
ML_PYTHON: ${{ github.workspace }}/ml/.venv/bin/python
run: uv run pytest -m chaine --no-cov
sast:
name: Analyse statique de sécurité
runs-on: ubuntu-latest
defaults:
run:
working-directory: ml
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@v7
- name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
- name: Rapport complet, tous niveaux
continue-on-error: true
run: uvx bandit==1.9.4 --recursive enervision_ml
+58 -18
View File
@@ -5,11 +5,15 @@ on:
paths: paths:
- "apps/frontend/**" - "apps/frontend/**"
- "apps/backend/**" - "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml" - ".github/workflows/sonarqube.yml"
pull_request: pull_request:
paths: paths:
- "apps/frontend/**" - "apps/frontend/**"
- "apps/backend/**" - "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml" - ".github/workflows/sonarqube.yml"
@@ -20,10 +24,10 @@ jobs:
build-front: build-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -36,10 +40,10 @@ jobs:
needs: build-front needs: build-front
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -52,7 +56,7 @@ jobs:
working-directory: apps/frontend working-directory: apps/frontend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info path: apps/frontend/coverage/frontend/lcov.info
@@ -60,9 +64,9 @@ jobs:
build-back: build-back:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -91,9 +95,9 @@ jobs:
needs: build-back needs: build-back
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -103,29 +107,65 @@ jobs:
working-directory: apps/backend working-directory: apps/backend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: backend-coverage name: backend-coverage
path: apps/backend/coverage.xml path: apps/backend/coverage.xml
sonarqube: test-ml:
needs: [build-front, build-back, test-front, test-back]
name: SonarQube
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: ml
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: ml
- name: Lancement des tests et génération du rapport de couverture (ML)
run: uv run pytest --cov-report=xml
working-directory: ml
- name: Upload coverage
uses: actions/upload-artifact@v7
with:
name: ml-coverage
path: ml/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back, test-ml]
name: SonarQube
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front) - name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back) - name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: backend-coverage name: backend-coverage
path: apps/backend path: apps/backend
- name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v8
with:
name: ml-coverage
path: ml
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8 uses: SonarSource/sonarqube-scan-action@v8
env: env:
+8 -2
View File
@@ -40,7 +40,6 @@ kubeconfig
# Airflow # Airflow
etl/airflow/logs/ etl/airflow/logs/
airflow.db airflow.db
airflow-webserver.pid
standalone_admin_password.txt standalone_admin_password.txt
# Environnement et secrets # Environnement et secrets
@@ -64,7 +63,14 @@ ml/models/*
!ml/models/.gitkeep !ml/models/.gitkeep
ml/mlruns/ ml/mlruns/
ml/mlartifacts/ ml/mlartifacts/
ml/mlflow.db ml/mlflow.db*
ml/.env
# 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 # IDE et OS
.idea/ .idea/
+164 -8
View File
@@ -1,17 +1,58 @@
BACKEND := apps/backend BACKEND := apps/backend
FRONTEND := apps/frontend FRONTEND := apps/frontend
ML := ml 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
# Piege : make ne lit pas `.env`, que seul docker compose interpole. Les cibles hors conteneur
# (ml-*, demo-data, db-wait) joignent la base par le port publie et ont besoin de ces valeurs.
env-val = $(shell sed -n 's/^$(1)=//p' .env 2>/dev/null | tail -1)
PG_USER := $(or $(strip $(call env-val,POSTGRES_USER)),enervision)
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
PG_PORT := $(or $(strip $(call env-val,POSTGRES_PORT)),5433)
ml-env-val = $(shell sed -n 's/^$(1)=//p' ml/.env 2>/dev/null | tail -1)
ML_ENV_DB_PASSWORD := $(call ml-env-val,MLFLOW_DB_PASSWORD)
AIRFLOW_PORT := $(or $(strip $(call env-val,AIRFLOW_PORT)),8080)
MAILPIT_UI_PORT := $(or $(strip $(call env-val,MAILPIT_UI_PORT)),8025)
ML_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DB)
export ML_DATABASE_URL
# Piege : la base des tests d'integration n'est pas la base de developpement. Ces tests ecrivent
# et suppriment des lignes, et leurs fixtures refusent de demarrer ailleurs que sur
# `enervision_test` (garde sur le nom, cf. ml/tests/conftest.py).
PG_TEST_DB ?= enervision_test
TEST_DATABASE_URL ?= postgresql+asyncpg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
ML_TEST_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
DEMO_NOW ?= 2024-12-31T00:00:00Z
.DEFAULT_GOAL := help .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 \
lint format typecheck test test-cov test-integration check \ dev dev-backend dev-frontend \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ lint format typecheck test test-cov test-integration ml-test-integration \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score test-chaine check \
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
migrate migrate-test bootstrap-admin services-up demo-data demo-data-force \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up 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 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}' @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 install-backend: ## Installe les dépendances du backend
cd $(BACKEND) && uv sync --all-groups cd $(BACKEND) && uv sync --all-groups
@@ -22,12 +63,22 @@ install-frontend: ## Installe les dépendances du frontend
install-ml: ## Installe les dépendances du pipeline ML install-ml: ## Installe les dépendances du pipeline ML
cd $(ML) && uv sync --all-groups cd $(ML) && uv sync --all-groups
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
cd $(AIRFLOW) && uv sync --all-groups
dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airflow, puis backend et frontend
@echo "airflow -> http://localhost:$(AIRFLOW_PORT) mailpit -> http://localhost:$(MAILPIT_UI_PORT)"
@trap 'kill 0' EXIT INT TERM; \ @trap 'kill 0' EXIT INT TERM; \
$(MAKE) --no-print-directory dev-backend & \ $(MAKE) --no-print-directory dev-backend & \
$(MAKE) --no-print-directory dev-frontend & \ $(MAKE) --no-print-directory dev-frontend & \
wait wait
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow)
docker compose up -d db mailpit
@$(MAKE) --no-print-directory db-wait
@$(MAKE) --no-print-directory db-ensure-airflow
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
dev-backend: ## Lance l'API seule en rechargement à chaud dev-backend: ## Lance l'API seule en rechargement à chaud
@echo "backend -> http://localhost:8000 (docs sur /docs)" @echo "backend -> http://localhost:8000 (docs sur /docs)"
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
@@ -71,15 +122,89 @@ ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base n
ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML
# La cible surcharge ML_DATABASE_URL, que ce Makefile exporte vers la base de développement : la
# garde du conftest ferait échouer la cible sans cette surcharge.
ml-test-integration: ML_DATABASE_URL := $(ML_TEST_DATABASE_URL)
ml-test-integration: ## Tests ML exigeant une base migrée. Faire `make db-up migrate-test` avant
cd $(ML) && uv run pytest -m integration --no-cov
test-chaine: ## Chaîne ML -> DB -> API, vrais binaires. Exige les deux environnements uv
cd $(BACKEND) && DATABASE_URL=$(TEST_DATABASE_URL) ML_PYTHON=$(CURDIR)/$(ML)/.venv/bin/python \
uv run pytest -m chaine --no-cov
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL 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),) 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 ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) $(if $(NOW),--now $(NOW),)
mlflow-up: ## Démarre le serveur MLflow (tracking + registry) en conteneur. ml/.env requis
@test -n "$(strip $(ML_ENV_DB_PASSWORD))" \
|| { echo "MLFLOW_DB_PASSWORD absente de ml/.env (copier ml/.env.example)"; exit 1; }
@echo "$(ML_ENV_DB_PASSWORD)" | grep -qE '^[A-Za-z0-9]+$$' \
|| { echo "MLFLOW_DB_PASSWORD doit contenir uniquement lettres et chiffres (interpolee dans l'URI postgresql://)"; exit 1; }
cd $(ML) && docker compose -f docker-compose.mlflow.yml up -d --build
@echo "mlflow -> http://localhost:5000"
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: db-ensure-airflow ## Démarre Airflow (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow
docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor
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: ## Construit l'image du backend
docker build -t enervision-backend:local $(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,)
# Piège : l'image backend ne migre pas au démarrage, et `/health/ready` ne teste que la connexion
# et l'extension. Sans `alembic upgrade head`, la stack démarre verte sur une base sans schéma.
stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. 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
$(COMPOSE_PROD) exec -T backend alembic upgrade head
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 db-up: ## Démarre la base PostgreSQL TimescaleDB
docker compose up -d db docker compose up -d db
@@ -95,8 +220,39 @@ db-logs: ## Suit les journaux de la base
db-psql: ## Ouvre une session psql sur la base applicative db-psql: ## Ouvre une session psql sur la base applicative
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision} docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
db-wait: ## Attend que la base accepte les connexions
@for _ in $$(seq 1 60); do \
docker compose exec -T db pg_isready -U $(PG_USER) -d $(PG_DB) >/dev/null 2>&1 && exit 0; \
sleep 1; \
done; \
echo "La base n'accepte toujours pas de connexion apres 60s"; exit 1
# Piege : db/init ne rejoue qu'a la premiere initialisation du volume. Un `pgdata` cree avant
# db/init/120-airflow-database.sql n'a pas de base `airflow`, et airflow-init boucle dessus.
db-ensure-airflow: ## Crée la base de métadonnées Airflow si le volume pgdata est antérieur à db/init/120
@docker compose exec -T db psql -U $(PG_USER) -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname = 'airflow'" | grep -q 1 \
|| docker compose exec -T db psql -U $(PG_USER) -d postgres -c "CREATE DATABASE airflow"
migrate: ## Applique les migrations Alembic migrate: ## Applique les migrations Alembic
cd $(BACKEND) && uv run alembic upgrade head cd $(BACKEND) && uv run alembic upgrade head
migrate-test: ## Applique les migrations sur enervision_test, la base des tests d'intégration
cd $(BACKEND) && DATABASE_URL=$(TEST_DATABASE_URL) uv run alembic upgrade head
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis} cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
demo-data: ## Renseigne prédictions, alertes et recommandations si elles manquent. NOW= optionnel
@nombre=$$(docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -tAc 'SELECT count(*) FROM alert') \
|| { echo "demo-data : base injoignable ou migrations non appliquees"; exit 1; }; \
if [ "$$nombre" = 0 ]; then \
$(MAKE) --no-print-directory demo-data-force; \
else \
echo "demo-data : $$nombre alerte(s) deja en base (make demo-data-force pour rejouer)"; \
fi
demo-data-force: ## Rejoue le peuplement sans regarder l'existant. Les trois etapes sont idempotentes
$(MAKE) --no-print-directory ml-score NOW=$(DEMO_NOW)
$(MAKE) --no-print-directory detect-alerts NOW=$(DEMO_NOW)
$(MAKE) --no-print-directory recommendations
+74 -20
View File
@@ -9,8 +9,10 @@ series temporelles energetiques, deployee sur une machine on-premise.
|-------|----------------------------------------------------------| |-------|----------------------------------------------------------|
| J1 | Valider la préparation de l'environnement et du repo | | J1 | Valider la préparation de l'environnement et du repo |
| J2 | Valider le périmètre retenu et les choix technologiques | | J2 | Valider le périmètre retenu et les choix technologiques |
| J3 | Valider l'architecture et la gestion de la sécurité | | J3 | Ingestion & backend |
| J4 | Valider la robustesse et assurer les livrables | | J4 | Architecture, sécurité & frontend |
| J5 | Valider la robustesse et assurer les livrables |
| J6 | Amélioration possible |
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md). Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
@@ -18,14 +20,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Domaine | Technologie | Emplacement | Etat | | Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------| |------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| ETL | Apache Airflow | `etl/airflow` | A initialiser | | ETL | Apache Airflow | `etl/airflow` | Cinq DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | En place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise | | ML | LightGBM, MLflow | `ml` | En place |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
@@ -47,11 +50,13 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── migrations/ Migrations SQL versionnees │ ├── migrations/ Migrations SQL versionnees
│ └── seeds/ Jeux de donnees de reference │ └── seeds/ Jeux de donnees de reference
├── etl/airflow/ ├── etl/airflow/
│ ├── dags/ DAGs d'ingestion et d'agregation │ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive)
│ ├── plugins/ Operateurs et hooks maison │ ├── plugins/ Operateurs et hooks maison
│ ├── include/ Requetes SQL et ressources des DAGs │ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs │ └── tests/ Tests d'integrite des DAGs
├── infra/terraform/ ├── infra/
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
│ └── terraform/
│ ├── modules/ Modules reutilisables │ ├── modules/ Modules reutilisables
│ └── environments/ Racines Terraform, une par environnement │ └── environments/ Racines Terraform, une par environnement
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow ├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
@@ -72,25 +77,53 @@ Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Pyth
cp .env.example .env # variables de docker-compose cp .env.example .env # variables de docker-compose
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433 make install # dependances du backend, du frontend, du ML et des DAGs
make install # dependances du backend et du frontend make dev # toute la stack, voir ci-dessous
make migrate # applique les migrations Alembic
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
make check # lint + typage + tests make check # lint + typage + tests
``` ```
`make dev` enchaine tout : demarrage des services conteneurises (base sur le port 5433, Mailpit,
Airflow), migrations Alembic, peuplement de demonstration si les alertes manquent, puis backend
et frontend en rechargement a chaud sur le poste.
| Service | Adresse |
|---|---|
| Backend | <http://localhost:8000> (documentation sur `/docs`) |
| Frontend | <http://localhost:4200> |
| Airflow | <http://localhost:8080> (`AIRFLOW_ADMIN_USERNAME` / `AIRFLOW_ADMIN_PASSWORD` du `.env`) |
| Mailpit | <http://localhost:8025> |
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
`AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
`airflow-scheduler` et `airflow-dag-processor` avec lui.
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
`make help` liste les cibles disponibles. `make help` liste les cibles disponibles.
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`, Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que `apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
5432, souvent deja pris par une autre base. 5432, souvent deja pris par une autre base.
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en Le backend et le frontend tournent sur le poste, lances ensemble par `make dev` (logs
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par entrelaces dans le meme terminal, Ctrl+C arrete les deux) ; la base, Mailpit et Airflow tournent
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend` en conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend` et n'embarque pas le source, donc toute modification y demande un
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc `docker compose up -d --build backend`.
toute modification y demande un `docker compose up -d --build backend`.
### Donnees de demonstration
Le jeu historique s'arrete au 31/12/2024. `make demo-data` renseigne les tables que les vues
alertes, recommandations et previsions lisent, en ancrant le scoring et la detection a cette
date (`DEMO_NOW`) plutot qu'a l'horloge reelle, qui ne verrait qu'un parc muet depuis des mois.
La cible ne fait rien si des alertes existent deja ; `make demo-data-force` rejoue les trois
etapes, toutes idempotentes en base.
Un volume `pgdata` cree avant `db/init/120-airflow-database.sql` n'a pas de base `airflow` :
`db/init` ne rejoue qu'a la premiere initialisation. `make db-ensure-airflow`, appelee par
`make dev` et `make airflow-up`, la cree au besoin, sans detruire les donnees applicatives.
Verifier que la base repond et que l'extension est chargee : Verifier que la base repond et que l'extension est chargee :
@@ -98,6 +131,27 @@ Verifier que la base repond et que l'extension est chargee :
curl -s localhost:8000/api/v1/health/ready 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).
Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`,
chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le
workflow `deploy.yml` les redéploie à chaque push par un runner auto-hébergé. Ports, noms
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
[l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
## Conventions ## Conventions
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
+12 -7
View File
@@ -1,3 +1,8 @@
# syntax=docker/dockerfile:1
FROM docker
COPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx
RUN docker buildx version
FROM python:3.14-slim AS builder FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/
@@ -8,16 +13,16 @@ ENV UV_COMPILE_BYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \ COPY uv.lock pyproject.toml /app/
--mount=type=bind,source=uv.lock,target=uv.lock \ RUN uv sync --locked --no-install-project --no-dev
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
COPY . /app COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \ RUN uv sync --locked --no-dev
uv sync --locked --no-dev
FROM python:3.14-slim AS runtime FROM python:3.14-slim AS runtime
+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/sites/{site_id}` | Décrit un site | `lecteur` |
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` | | `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `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` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
@@ -0,0 +1,81 @@
"""rapports de derive du modele de prevision
Revision ID: d3f1a2b7c904
Revises: c0adab96238c
Create Date: 2026-09-22 14:40:00.000000
`site_id` est nullable, et c'est le coeur du schema : une ligne par site, plus une ligne
globale tous sites confondus, que `NULL` designe. Un seul site qui derive est invisible dans
une moyenne d'ensemble, et une derive d'ensemble sans rupture par site signale un changement
de modele ou de saison, pas une panne.
L'unicite passe par un index a `coalesce` et non par une `UniqueConstraint` : deux lignes
globales successives ont toutes deux `site_id` a NULL, et NULL n'est egal a aucune valeur, pas
meme a lui-meme. Meme forme que `uq_reading_source`.
Les trois `CHECK` sont portees par la base, comme `ck_prediction_status` : un verdict sans
motif, ou un statut inconnu, ne doit pas dependre de la vigilance de l'appelant.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "d3f1a2b7c904"
down_revision: str | Sequence[str] | None = "c0adab96238c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"drift_report",
sa.Column("drift_report_id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("site_id", sa.Text(), nullable=True),
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("reference_start", sa.DateTime(timezone=True), nullable=True),
sa.Column("reference_end", sa.DateTime(timezone=True), nullable=True),
sa.Column("n_observations", sa.Integer(), nullable=False),
sa.Column("mae", sa.Double(), nullable=True),
sa.Column("mape", sa.Double(), nullable=True),
sa.Column("bias", sa.Double(), nullable=True),
sa.Column("reference_mae", sa.Double(), nullable=True),
sa.Column("coverage_ratio", sa.Double(), nullable=True),
sa.Column("insufficient_data_ratio", sa.Double(), nullable=True),
sa.Column("model_references", postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.CheckConstraint(
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
),
sa.CheckConstraint(
"status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"
),
sa.CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
sa.ForeignKeyConstraint(
["site_id"], ["site.site_id"], name="fk_drift_report_site", ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("drift_report_id"),
)
op.create_index(
"ix_drift_report_site_computed", "drift_report", ["site_id", "computed_at"], unique=False
)
op.create_index(
"uq_drift_report_window",
"drift_report",
["window_end", sa.literal_column("coalesce(site_id, '')")],
unique=True,
)
def downgrade() -> None:
op.drop_table("drift_report")
+17 -2
View File
@@ -24,6 +24,7 @@ from app.core.security import decode_access_token as decode_token
from app.db.session import get_session from app.db.session import get_session
from app.repositories.alert import AlertRepository from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository from app.repositories.audit_log import AuditLogRepository
from app.repositories.drift import DriftRepository
from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.password_reset_token import PasswordResetTokenRepository
@@ -35,6 +36,7 @@ from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository from app.repositories.user import UserRepository
from app.services.alert import AlertService from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
from app.services.drift import DriftService
from app.services.prediction import PredictionService from app.services.prediction import PredictionService
from app.services.reading import ReadingService from app.services.reading import ReadingService
from app.services.recommendation import RecommendationService from app.services.recommendation import RecommendationService
@@ -48,7 +50,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
CODE_CHANGEMENT_REQUIS = "password_change_required" CODE_CHANGEMENT_REQUIS = "password_change_required"
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès") # Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un nom
# de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
_porteur = HTTPBearer(auto_error=False, scheme_name="JetonAcces")
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)] CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
@@ -192,7 +196,11 @@ AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
def get_recommendation_service(session: SessionDep) -> RecommendationService: 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)] RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
@@ -228,6 +236,13 @@ def get_prediction_service(session: SessionDep) -> PredictionService:
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)] PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
def get_drift_service(session: SessionDep) -> DriftService:
return DriftService(DriftRepository(session))
DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
async def get_current_principal( async def get_current_principal(
credentials: CredentialsDep, credentials: CredentialsDep,
session: SessionDep, session: SessionDep,
+23 -2
View File
@@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "recommendations", "name": "recommendations",
"description": ( "description": (
"Consultation des recommandations issues des alertes. Accessible à partir du rôle " "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`."
), ),
}, },
{ {
@@ -90,11 +90,21 @@ TAGS: Final[list[dict[str, Any]]] = [
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." "de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
), ),
}, },
{
"name": "monitoring",
"description": (
"Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et "
"les lectures réellement arrivées, par site et tous sites confondus. Réservé à "
"partir du rôle `operateur`, qui agit sur un pipeline dégradé."
),
},
] ]
cookie_de_rafraichissement = APIKeyCookie( cookie_de_rafraichissement = APIKeyCookie(
name=REFRESH_COOKIE_DEFAUT, name=REFRESH_COOKIE_DEFAUT,
scheme_name="Cookie de rafraîchissement", # Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un
# nom de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
scheme_name="CookieRafraichissement",
description=( description=(
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le " "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers " "préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
@@ -153,6 +163,17 @@ REPONSES_ADMIN: Final[Reponses] = {
}, },
} }
REPONSES_OPERATEUR: Final[Reponses] = {
**REPONSES_AUTHENTIFIEES,
403: {
"model": ErrorResponse,
"description": (
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
"`password_change_required`."
),
},
}
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits # `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
# insuffisants, seulement pour le mot de passe provisoire. # insuffisants, seulement pour le mot de passe provisoire.
REPONSES_LECTEUR: Final[Reponses] = { REPONSES_LECTEUR: Final[Reponses] = {
@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.api.deps import DriftServiceDep, OperateurDep
from app.api.openapi import REPONSE_VALIDATION
from app.schemas.drift import DriftReportResponse
router = APIRouter()
@router.get(
"/drift",
response_model=list[DriftReportResponse],
summary="Dernier rapport de dérive par site, plus la ligne globale",
responses=REPONSE_VALIDATION,
)
async def get_drift(
_: OperateurDep, service: DriftServiceDep, site_id: str | None = None
) -> list[DriftReportResponse]:
rapports = await service.derniers(site_id=site_id)
return [DriftReportResponse.model_validate(rapport) for rapport in rapports]
@@ -1,13 +1,18 @@
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, RecommendationServiceDep from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses
from app.schemas.errors import ErrorResponse 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 from app.services.recommendation import RecommendationNotFoundError
router = APIRouter() router = APIRouter()
REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION}
REPONSES_INTROUVABLE: Reponses = { REPONSES_INTROUVABLE: Reponses = {
**REPONSE_VALIDATION, **REPONSE_VALIDATION,
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."}, 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" status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
) from erreur ) from erreur
return RecommendationResponse.model_validate(recommendation) 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,
)
+10 -1
View File
@@ -1,10 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR from app.api.openapi import (
REPONSE_SERVEUR,
REPONSES_ADMIN,
REPONSES_LECTEUR,
REPONSES_OPERATEUR,
)
from app.api.v1.endpoints import ( from app.api.v1.endpoints import (
alerts, alerts,
auth, auth,
health, health,
monitoring,
predictions, predictions,
readings, readings,
recommendations, recommendations,
@@ -38,3 +44,6 @@ api_router.include_router(
api_router.include_router( api_router.include_router(
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
) )
api_router.include_router(
monitoring.router, prefix="/monitoring", tags=["monitoring"], responses=REPONSES_OPERATEUR
)
+31
View File
@@ -22,8 +22,11 @@ from app.core.hashing import build_hasher
from app.core.roles import Role from app.core.roles import Role
from app.db.session import get_session_factory from app.db.session import get_session_factory
from app.main import create_app 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.repositories.user import UserRepository
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
from app.services.recommendation import RecommendationService
LONGUEUR_MOT_DE_PASSE_GENERE = 24 LONGUEUR_MOT_DE_PASSE_GENERE = 24
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" 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 # 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 # 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. # 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" "export-openapi", help="Écrit le contrat OpenAPI sur disque"
) )
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT)) 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 return parser
@@ -152,6 +179,10 @@ def main(argv: list[str] | None = None) -> int:
print(export_openapi(Path(arguments.output))) print(export_openapi(Path(arguments.output)))
return 0 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) mot_de_passe = read_password(generate=arguments.generate)
succes, message = asyncio.run( succes, message = asyncio.run(
+130 -25
View File
@@ -1,3 +1,11 @@
# 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 from __future__ import annotations
import argparse import argparse
@@ -14,23 +22,93 @@ from app.core.config import get_settings
SOURCE_HISTORY = "api_history" 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: def create_mock_api_client() -> httpx.AsyncClient:
settings = get_settings() settings = get_settings()
if settings.mock_api_username is None or settings.mock_api_password is None: username = settings.mock_api_username
password = (
settings.mock_api_password.get_secret_value()
if settings.mock_api_password is not None
else None
)
if not username or not username.strip() or not password or not password.strip():
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.") raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
return httpx.AsyncClient( return httpx.AsyncClient(
base_url=settings.mock_api_base_url.rstrip("/"), base_url=settings.mock_api_base_url.rstrip("/"),
auth=( auth=(username, password),
settings.mock_api_username,
settings.mock_api_password.get_secret_value(),
),
timeout=settings.mock_api_timeout_seconds, 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( async def fetch_sites(
client: httpx.AsyncClient, client: httpx.AsyncClient,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@@ -43,14 +121,32 @@ async def fetch_sites(
if not isinstance(payload, list): if not isinstance(payload, list):
raise ValueError("La réponse /api/v1/sites doit être une liste.") 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 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( async def upsert_sites(
connection: AsyncConnection, connection: AsyncConnection,
sites: list[dict[str, Any]], sites: list[dict[str, Any]],
) -> None: ) -> None:
if not sites: rows = [build_site_row(site) for site in sites]
if not rows:
return return
await connection.execute( await connection.execute(
@@ -81,7 +177,7 @@ async def upsert_sites(
status = EXCLUDED.status status = EXCLUDED.status
""" """
), ),
sites, rows,
) )
@@ -90,7 +186,7 @@ async def fetch_readings(
site_id: str, site_id: str,
start_time: datetime, start_time: datetime,
end_time: datetime, end_time: datetime,
limit: int = 1000, limit: int = MAX_LIMIT,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
response = await client.get( response = await client.get(
"/api/v1/readings", "/api/v1/readings",
@@ -109,30 +205,36 @@ async def fetch_readings(
if not isinstance(payload, list): if not isinstance(payload, list):
raise ValueError("La réponse /api/v1/readings doit être une liste.") 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 return payload
def build_reading_row( def build_reading_row(
reading: dict[str, Any], reading: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
timestamp = datetime.fromisoformat(reading["timestamp"].replace("Z", "+00:00")) 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 { return {
"site_id": reading["site_id"], "site_id": read_text(reading, "site_id"),
"timestamp": timestamp, "timestamp": parse_datetime(read_text(reading, "timestamp")),
"source": SOURCE_HISTORY, "source": SOURCE_HISTORY,
"dataset_id": None, "dataset_id": None,
"consumption_kw": reading.get("consumption_kw"), **measures,
"consumption_kwh": reading.get("consumption_kwh"),
"consumption_euros": None, "consumption_euros": None,
"voltage_v": reading.get("voltage_v"),
"current_a": reading.get("current_a"),
"power_factor": reading.get("power_factor"),
"temperature_celsius": reading.get("temperature_celsius"),
"humidity_percent": reading.get("humidity_percent"),
"solar_irradiance_wm2": None, "solar_irradiance_wm2": None,
"is_working_hours": None, "is_working_hours": None,
"data_quality": reading.get("data_quality"), "data_quality": resolve_quality(reading.get("data_quality"), rejected),
"null_reasons": reading.get("null_reasons"), "null_reasons": resolve_null_reasons(reading.get("null_reasons"), rejected),
"imputed_values": None, "imputed_values": None,
"imputation_method": None, "imputation_method": None,
"raw_data": json.dumps( "raw_data": json.dumps(
@@ -142,6 +244,8 @@ def build_reading_row(
} }
# 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( READING_INSERT = text(
""" """
INSERT INTO reading ( INSERT INTO reading (
@@ -186,7 +290,8 @@ READING_INSERT = text(
:imputation_method, :imputation_method,
CAST(:raw_data AS jsonb) CAST(:raw_data AS jsonb)
) )
ON CONFLICT DO NOTHING ON CONFLICT (site_id, timestamp, source, (coalesce(dataset_id, 0)))
DO NOTHING
""" """
) )
@@ -213,7 +318,7 @@ async def import_mock_api_history(
all_readings: list[dict[str, Any]] = [] all_readings: list[dict[str, Any]] = []
for site in sites: for site in sites:
site_id = site["site_id"] site_id = read_text(site, "site_id")
readings = await fetch_readings( readings = await fetch_readings(
client=client, client=client,
@@ -281,7 +386,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--limit", "--limit",
type=int, type=int,
default=1000, default=MAX_LIMIT,
) )
parser.add_argument( parser.add_argument(
@@ -295,8 +400,8 @@ def parse_args() -> argparse.Namespace:
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
if args.limit < 1 or args.limit > 1000: if args.limit < 1 or args.limit > MAX_LIMIT:
raise ValueError("--limit doit être compris entre 1 et 1000.") raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
if args.start_time >= args.end_time: if args.start_time >= args.end_time:
raise ValueError("--start-time doit être antérieur à --end-time.") raise ValueError("--start-time doit être antérieur à --end-time.")
+10 -1
View File
@@ -2,7 +2,15 @@
# --autogenerate`, qui générerait alors un drop de sa table. # --autogenerate`, qui générerait alors un drop de sa table.
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.energy import (
Alert,
Dataset,
DriftReport,
Prediction,
Reading,
Recommendation,
Site,
)
from app.models.login_attempt import LoginAttempt from app.models.login_attempt import LoginAttempt
from app.models.password_reset_attempt import PasswordResetAttempt from app.models.password_reset_attempt import PasswordResetAttempt
from app.models.password_reset_token import PasswordResetToken from app.models.password_reset_token import PasswordResetToken
@@ -14,6 +22,7 @@ __all__ = [
"AppUser", "AppUser",
"AuditLog", "AuditLog",
"Dataset", "Dataset",
"DriftReport",
"LoginAttempt", "LoginAttempt",
"PasswordResetAttempt", "PasswordResetAttempt",
"PasswordResetToken", "PasswordResetToken",
+46
View File
@@ -208,3 +208,49 @@ class Recommendation(Base):
explanation: Mapped[str] = mapped_column(Text) explanation: Mapped[str] = mapped_column(Text)
rule_reference: Mapped[str] = mapped_column(Text) rule_reference: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DriftReport(Base):
__tablename__ = "drift_report"
__table_args__ = (
CheckConstraint(
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
),
CheckConstraint("status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"),
CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
Index("ix_drift_report_site_computed", "site_id", "computed_at"),
)
drift_report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
computed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# `NULL` porte la ligne globale, tous sites confondus : une derive d'ensemble et la derive
# d'un seul site ne se lisent pas dans le meme chiffre.
site_id: Mapped[str | None] = mapped_column(
Text, ForeignKey("site.site_id", name="fk_drift_report_site", ondelete="RESTRICT")
)
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True))
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True))
reference_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reference_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
n_observations: Mapped[int] = mapped_column(Integer)
mae: Mapped[float | None] = mapped_column(Double)
mape: Mapped[float | None] = mapped_column(Double)
bias: Mapped[float | None] = mapped_column(Double)
reference_mae: Mapped[float | None] = mapped_column(Double)
coverage_ratio: Mapped[float | None] = mapped_column(Double)
insufficient_data_ratio: Mapped[float | None] = mapped_column(Double)
model_references: Mapped[list[str]] = mapped_column(ARRAY(Text))
status: Mapped[str] = mapped_column(Text)
reason: Mapped[str | None] = mapped_column(Text)
# Piège : une `UniqueConstraint` ne dédoublonnerait pas les lignes globales, dont `site_id` est
# NULL et qu'aucune n'est égale à une autre. Même forme que `uq_reading_source`.
Index(
"uq_drift_report_window",
DriftReport.window_end,
func.coalesce(DriftReport.site_id, text("''")),
unique=True,
)
+115
View File
@@ -0,0 +1,115 @@
# Surveillance de dérive du modèle de prévision (EC06, issue #45) : même gabarit que
# `app.detection.internal_alerts`, ordonnancé par le DAG `derive`.
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime, timedelta
from app.core.config import get_settings
from app.db.session import get_session_factory
from app.repositories.drift import DriftRepository, NouveauRapportDerive
from app.services.drift import STATUT_DERIVE, DriftService, Seuils
async def run_drift(
*, now: datetime | None = None, site_id: str | None = None, seuils: Seuils | None = None
) -> list[NouveauRapportDerive]:
"""Calcule les rapports de la fenêtre et les enregistre. Rend ce qui a été calculé, que la
ligne ait été écrite ou ignorée par l'index d'idempotence."""
async with get_session_factory()() as session:
depot = DriftRepository(session)
rapports = await DriftService(depot, seuils=seuils).evaluate(now=now, site_id=site_id)
await depot.enregistre(rapports)
await session.commit()
return rapports
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:
defauts = Seuils()
parser = argparse.ArgumentParser(
prog="python -m app.monitoring.drift",
description="Surveillance de dérive du modèle de prévision EnerVision",
)
parser.add_argument("--site-id", default=None, help="Limite le calcul à 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."
),
)
parser.add_argument(
"--window-hours",
type=int,
default=int(defauts.fenetre.total_seconds() // 3600),
help="Durée de la fenêtre récente, et de la fenêtre de référence qui la précède.",
)
parser.add_argument(
"--grace-hours",
type=int,
default=int(defauts.grace.total_seconds() // 3600),
help="Délai laissé à l'ingestion avant qu'une prévision soit jugée vérifiable.",
)
parser.add_argument(
"--min-observations",
type=int,
default=defauts.min_observations,
help="En deçà, le verdict est `indetermine` plutôt qu'un chiffre trompeur.",
)
parser.add_argument(
"--bias-threshold",
type=float,
default=defauts.seuil_biais,
help=(
"Biais absolu en kWh au-delà duquel le verdict bascule en dérive. "
"Zéro, le défaut, laisse le biais informatif : voir l'ADR 0013."
),
)
parser.add_argument(
"--fail-on-drift",
action="store_true",
help="Sort en code non nul si une dérive est constatée, pour que la tâche rougisse.",
)
return parser.parse_args(argv)
def seuils_depuis(args: argparse.Namespace) -> Seuils:
return Seuils(
fenetre=timedelta(hours=args.window_hours),
grace=timedelta(hours=args.grace_hours),
min_observations=args.min_observations,
seuil_biais=args.bias_threshold,
)
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()
rapports = asyncio.run(
run_drift(now=args.now, site_id=args.site_id, seuils=seuils_depuis(args))
)
for rapport in rapports:
cible = rapport.site_id or "TOUS SITES"
mae = f"{rapport.mae:.2f}" if rapport.mae is not None else "-"
print(
f"{cible} : {rapport.status}, MAE {mae} kWh sur {rapport.n_observations} prévision(s)"
f"{' : ' + rapport.reason if rapport.reason else ''}"
)
derive = any(rapport.status == STATUT_DERIVE for rapport in rapports)
return 1 if derive and args.fail_on_drift else 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+11 -2
View File
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert from app.models.energy import Alert
# Douze colonnes par alerte, contre quatre pour une recommandation : le plafond asyncpg de
# 32 767 parametres tombe a 2 730 lignes, d'ou un lot plus petit que `recommendation.py`.
TAILLE_DE_LOT = 1000
class AlertRepository: class AlertRepository:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
@@ -44,12 +48,17 @@ class AlertRepository:
} }
for alerte in alerts for alerte in alerts
] ]
creees: list[Alert] = []
# Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre
# chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait.
for debut in range(0, len(valeurs), TAILLE_DE_LOT):
requete = ( requete = (
insert(Alert) insert(Alert)
.values(valeurs) .values(valeurs[debut : debut + TAILLE_DE_LOT])
.on_conflict_do_nothing(constraint="uq_alert_source_reference") .on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert) .returning(Alert)
) )
resultat = await self._session.execute(requete) resultat = await self._session.execute(requete)
creees.extend(resultat.scalars().all())
await self._session.flush() await self._session.flush()
return resultat.scalars().all() return creees
+184
View File
@@ -0,0 +1,184 @@
"""Piège : deux dédoublonnages, pas un - DriftRepository.paires()
`prediction` n'a pas d'unicité sur `(site_id, target_at)` : chaque run de scoring empile une
ligne de plus. `uq_reading_source` autorise de son côté deux lectures au même instant quand la
`source` diffère. Joindre les deux tables sans `DISTINCT ON` des deux côtés compterait donc la
même heure plusieurs fois, et la moyenne d'erreur pèserait ces sites en double.
On retient la prédiction du run le plus récent, celle que sert `GET /api/v1/predictions`, avec
`prediction_id` en départage : `created_at` vaut l'heure de début de transaction et ne
distingue pas deux lignes du même run.
"""
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from datetime import datetime
from sqlalchemy import Subquery, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import DriftReport, Prediction, Reading
TARGET_METRIC = "consumption_kwh"
STATUT_DISPONIBLE = "available"
@dataclass(frozen=True, slots=True)
class PaireDerive:
site_id: str
target_at: datetime
predicted_value: float
actual_value: float
model_reference: str
@dataclass(frozen=True, slots=True)
class NouveauRapportDerive:
site_id: str | None
window_start: datetime
window_end: datetime
reference_start: datetime | None
reference_end: datetime | None
n_observations: int
mae: float | None
mape: float | None
bias: float | None
reference_mae: float | None
coverage_ratio: float | None
insufficient_data_ratio: float | None
model_references: list[str]
status: str
reason: str | None
@dataclass(frozen=True, slots=True)
class ComptageStatut:
site_id: str
status: str
nombre: int
def _predictions_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
requete = (
select(
Prediction.site_id,
Prediction.target_at,
Prediction.predicted_value,
Prediction.model_reference,
Prediction.status,
)
.distinct(Prediction.site_id, Prediction.target_at)
.where(
Prediction.target_metric == TARGET_METRIC,
Prediction.target_at >= debut,
Prediction.target_at < fin,
)
.order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id.desc())
)
if site_id is not None:
requete = requete.where(Prediction.site_id == site_id)
return requete.subquery()
def _lectures_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
requete = (
select(Reading.site_id, Reading.timestamp, Reading.consumption_kwh)
.distinct(Reading.site_id, Reading.timestamp)
.where(
Reading.timestamp >= debut,
Reading.timestamp < fin,
Reading.consumption_kwh.is_not(None),
)
.order_by(Reading.site_id, Reading.timestamp, Reading.reading_id.desc())
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return requete.subquery()
class DriftRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def paires(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[PaireDerive]:
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
lectures = _lectures_retenues(debut=debut, fin=fin, site_id=site_id)
requete = (
select(
predictions.c.site_id,
predictions.c.target_at,
predictions.c.predicted_value,
lectures.c.consumption_kwh,
predictions.c.model_reference,
)
.select_from(predictions)
.join(
lectures,
(lectures.c.site_id == predictions.c.site_id)
& (lectures.c.timestamp == predictions.c.target_at),
)
.where(predictions.c.status == STATUT_DISPONIBLE)
.order_by(predictions.c.site_id, predictions.c.target_at)
)
lignes = await self._session.execute(requete)
return [
PaireDerive(
site_id=ligne[0],
target_at=ligne[1],
predicted_value=ligne[2],
actual_value=ligne[3],
model_reference=ligne[4],
)
for ligne in lignes
]
async def comptages(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[ComptageStatut]:
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
requete = (
select(predictions.c.site_id, predictions.c.status, func.count())
.select_from(predictions)
.group_by(predictions.c.site_id, predictions.c.status)
)
lignes = await self._session.execute(requete)
return [
ComptageStatut(site_id=ligne[0], status=ligne[1], nombre=ligne[2]) for ligne in lignes
]
# Pourquoi : l'idempotence est déléguée à `uq_drift_report_window` plutôt qu'à une lecture
# préalable, comme pour les recommandations. Rejouer la commande sur la même fenêtre ne
# duplique donc rien.
async def enregistre(self, rapports: Sequence[NouveauRapportDerive]) -> int:
if not rapports:
return 0
valeurs = [asdict(rapport) for rapport in rapports]
requete = (
insert(DriftReport)
.values(valeurs)
.on_conflict_do_nothing(
index_elements=[DriftReport.window_end, func.coalesce(DriftReport.site_id, "")]
)
.returning(DriftReport.drift_report_id)
)
return len((await self._session.scalars(requete)).all())
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
requete = (
select(DriftReport)
.distinct(DriftReport.site_id)
.order_by(
DriftReport.site_id,
DriftReport.computed_at.desc(),
DriftReport.drift_report_id.desc(),
)
)
if site_id is not None:
requete = requete.where(DriftReport.site_id == site_id)
return (await self._session.scalars(requete)).all()
@@ -1,11 +1,24 @@
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import asdict, dataclass
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Recommendation 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: class RecommendationRepository:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session self._session = session
@@ -20,3 +33,19 @@ class RecommendationRepository:
) )
recommendation: Recommendation | None = await self._session.scalar(requete) recommendation: Recommendation | None = await self._session.scalar(requete)
return recommendation 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
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class DriftStatus(StrEnum):
STABLE = "stable"
DERIVE = "derive"
INDETERMINE = "indetermine"
class DriftReportResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str | None
computed_at: datetime
window_start: datetime
window_end: datetime
reference_start: datetime | None
reference_end: datetime | None
n_observations: int
mae: float | None
mape: float | None
bias: float | None
reference_mae: float | None
coverage_ratio: float | None
insufficient_data_ratio: float | None
model_references: list[str]
status: DriftStatus
reason: str | None
@@ -12,3 +12,9 @@ class RecommendationResponse(BaseModel):
explanation: str explanation: str
rule_reference: str rule_reference: str
created_at: datetime created_at: datetime
class RecommendationGenerationResponse(BaseModel):
alerts_examined: int
recommendations_created: int
already_present: int
+233
View File
@@ -0,0 +1,233 @@
"""Contrainte : la dérive se mesure sur ce qui a déjà eu lieu - DriftService.evaluate()
Une prévision ne devient vérifiable que quand la lecture de son instant cible est ingérée. La
fenêtre est donc fermée à droite par un délai de grâce : sans lui, la dernière heure ferait
chuter le taux de couverture à chaque exécution, et le verdict dirait « dérive » alors que
seule l'ingestion n'avait pas fini son tour.
La comparaison se fait entre deux fenêtres vives de même durée, pas contre la métrique de
référence du modèle journalisée à l'entraînement. Ce ne sont pas les mêmes grandeurs :
l'entraînement mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit
une heure future dont la météo ne l'est pas. Les comparer classerait le modèle « en dérive »
dès le premier jour, ce qui ne prouverait rien.
"""
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
from app.models.energy import DriftReport
from app.repositories.drift import (
ComptageStatut,
DriftRepository,
NouveauRapportDerive,
PaireDerive,
)
STATUT_STABLE = "stable"
STATUT_DERIVE = "derive"
STATUT_INDETERMINE = "indetermine"
STATUT_INSUFFISANT = "insufficient_data"
STATUT_DISPONIBLE = "available"
@dataclass(frozen=True, slots=True)
class Seuils:
# 168 h, la saisonnalité hebdomadaire que le modèle apprend par son lag principal : une
# fenêtre plus courte comparerait un week-end à une semaine ouvrée.
fenetre: timedelta = timedelta(hours=168)
grace: timedelta = timedelta(hours=2)
min_observations: int = 24
ratio_derive: float = 1.25
mae_plancher: float = 0.0
# Un biais se compte en kWh, donc ne se transpose pas d'un site à l'autre : zéro le désactive,
# sans cesser de le mesurer. Réglé par `--bias-threshold`, arbitrage dans l'ADR 0013.
seuil_biais: float = 0.0
seuil_couverture: float = 0.8
@dataclass(frozen=True, slots=True)
class Metriques:
n_observations: int
mae: float | None
mape: float | None
bias: float | None
model_references: list[str]
def mesure(paires: Sequence[PaireDerive]) -> Metriques:
if not paires:
return Metriques(n_observations=0, mae=None, mape=None, bias=None, model_references=[])
ecarts = [paire.predicted_value - paire.actual_value for paire in paires]
# Le MAPE diverge sur une consommation nulle : les sites à l'arrêt sortent de ce seul
# rapport, jamais des autres métriques.
ratios = [
abs(ecart / paire.actual_value)
for ecart, paire in zip(ecarts, paires, strict=True)
if paire.actual_value != 0
]
return Metriques(
n_observations=len(paires),
mae=sum(abs(ecart) for ecart in ecarts) / len(ecarts),
mape=(sum(ratios) / len(ratios) * 100) if ratios else None,
bias=sum(ecarts) / len(ecarts),
model_references=sorted({paire.model_reference for paire in paires}),
)
@dataclass(frozen=True, slots=True)
class Verdict:
status: str
reason: str | None
class DriftService:
def __init__(self, depot: DriftRepository, *, seuils: Seuils | None = None) -> None:
self._depot = depot
self._seuils = seuils or Seuils()
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
"""Ce que sert l'API : le dernier rapport de chaque site, plus la ligne globale."""
return await self._depot.derniers(site_id=site_id)
async def evaluate(
self, *, now: datetime | None = None, site_id: str | None = None
) -> list[NouveauRapportDerive]:
"""Une ligne par site, plus une ligne globale dont le `site_id` est nul."""
fin = (now or datetime.now(UTC)) - self._seuils.grace
debut = fin - self._seuils.fenetre
reference_fin = debut
reference_debut = reference_fin - self._seuils.fenetre
recentes = await self._depot.paires(debut=debut, fin=fin, site_id=site_id)
anciennes = await self._depot.paires(
debut=reference_debut, fin=reference_fin, site_id=site_id
)
comptages = await self._depot.comptages(debut=debut, fin=fin, site_id=site_id)
gabarit = NouveauRapportDerive(
site_id=None,
window_start=debut,
window_end=fin,
reference_start=reference_debut,
reference_end=reference_fin,
n_observations=0,
mae=None,
mape=None,
bias=None,
reference_mae=None,
coverage_ratio=None,
insufficient_data_ratio=None,
model_references=[],
status=STATUT_INDETERMINE,
reason=None,
)
rapports = [
self._rapport(
gabarit,
site=site,
recentes=[p for p in recentes if p.site_id == site],
anciennes=[p for p in anciennes if p.site_id == site],
comptages=[c for c in comptages if c.site_id == site],
)
for site in sorted(
{paire.site_id for paire in recentes} | {c.site_id for c in comptages}
)
]
rapports.append(
self._rapport(
gabarit, site=None, recentes=recentes, anciennes=anciennes, comptages=comptages
)
)
return rapports
def _rapport(
self,
gabarit: NouveauRapportDerive,
*,
site: str | None,
recentes: Sequence[PaireDerive],
anciennes: Sequence[PaireDerive],
comptages: Sequence[ComptageStatut],
) -> NouveauRapportDerive:
metriques = mesure(recentes)
reference = mesure(anciennes)
couverture = _couverture(len(recentes), comptages)
verdict = self._verdict(metriques, reference_mae=reference.mae, couverture=couverture)
return replace(
gabarit,
site_id=site,
n_observations=metriques.n_observations,
mae=metriques.mae,
mape=metriques.mape,
bias=metriques.bias,
reference_mae=reference.mae,
coverage_ratio=couverture,
insufficient_data_ratio=_part_insuffisante(comptages),
model_references=metriques.model_references,
status=verdict.status,
reason=verdict.reason,
)
def _verdict(
self, metriques: Metriques, *, reference_mae: float | None, couverture: float | None
) -> Verdict:
seuils = self._seuils
if metriques.n_observations < seuils.min_observations:
return Verdict(
STATUT_INDETERMINE,
f"{metriques.n_observations} prévision(s) vérifiée(s) sur la fenêtre, "
f"minimum {seuils.min_observations}.",
)
if couverture is not None and couverture < seuils.seuil_couverture:
return Verdict(
STATUT_DERIVE,
f"Couverture de {couverture:.0%}, sous le seuil de {seuils.seuil_couverture:.0%} : "
"le pipeline, pas le modèle.",
)
plafond = _plafond(reference_mae, ratio=seuils.ratio_derive, plancher=seuils.mae_plancher)
if metriques.mae is not None and plafond is not None and metriques.mae > plafond:
return Verdict(
STATUT_DERIVE,
f"MAE de {metriques.mae:.2f} kWh au-delà de {plafond:.2f} kWh, "
"seuil dérivé de la fenêtre de référence.",
)
if (
seuils.seuil_biais > 0
and metriques.bias is not None
and abs(metriques.bias) > seuils.seuil_biais
):
return Verdict(
STATUT_DERIVE,
f"Biais de {metriques.bias:+.2f} kWh : le modèle se trompe toujours du même côté.",
)
return Verdict(STATUT_STABLE, None)
def _plafond(reference_mae: float | None, *, ratio: float, plancher: float) -> float | None:
if reference_mae is None:
return plancher or None
return max(plancher, reference_mae * ratio)
def _couverture(apparie: int, comptages: Sequence[ComptageStatut]) -> float | None:
"""Part des prévisions disponibles qui ont trouvé leur réalisé. Mesure l'ingestion et
l'ordonnancement, pas la qualité du modèle."""
disponibles = sum(c.nombre for c in comptages if c.status == STATUT_DISPONIBLE)
return apparie / disponibles if disponibles else None
def _part_insuffisante(comptages: Sequence[ComptageStatut]) -> float | None:
total = sum(c.nombre for c in comptages)
if not total:
return None
return sum(c.nombre for c in comptages if c.status == STATUT_INSUFFISANT) / total
+37 -1
View File
@@ -1,7 +1,15 @@
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from app.models.energy import Recommendation from app.models.energy import Recommendation
from app.repositories.alert import AlertRepository
from app.repositories.recommendation import RecommendationRepository 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): class RecommendationError(Exception):
@@ -12,9 +20,24 @@ class RecommendationNotFoundError(RecommendationError):
pass pass
@dataclass(frozen=True, slots=True)
class RapportGeneration:
alertes_examinees: int
recommandations_creees: int
deja_presentes: int
class RecommendationService: class RecommendationService:
def __init__(self, *, recommendations: RecommendationRepository) -> None: def __init__(
self,
*,
recommendations: RecommendationRepository,
alerts: AlertRepository,
transaction: Transaction,
) -> None:
self._recommendations = recommendations self._recommendations = recommendations
self._alerts = alerts
self._transaction = transaction
async def list_all(self) -> Sequence[Recommendation]: async def list_all(self) -> Sequence[Recommendation]:
return await self._recommendations.list_all() return await self._recommendations.list_all()
@@ -24,3 +47,16 @@ class RecommendationService:
if recommendation is None: if recommendation is None:
raise RecommendationNotFoundError(recommendation_id) raise RecommendationNotFoundError(recommendation_id)
return recommendation 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})"
+395 -22
View File
@@ -213,7 +213,7 @@
}, },
"security": [ "security": [
{ {
"Cookie de rafraîchissement": [] "CookieRafraichissement": []
} }
] ]
} }
@@ -252,7 +252,7 @@
}, },
"security": [ "security": [
{ {
"Cookie de rafraîchissement": [] "CookieRafraichissement": []
} }
] ]
} }
@@ -301,7 +301,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -347,7 +347,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -423,7 +423,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -673,7 +673,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
}, },
@@ -757,7 +757,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -771,7 +771,7 @@
"operationId": "update_user_api_v1_users__user_id__patch", "operationId": "update_user_api_v1_users__user_id__patch",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -889,7 +889,7 @@
"operationId": "reset_password_api_v1_users__user_id__password_reset_post", "operationId": "reset_password_api_v1_users__user_id__password_reset_post",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1023,7 +1023,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -1037,7 +1037,7 @@
"operationId": "get_site_api_v1_sites__site_id__get", "operationId": "get_site_api_v1_sites__site_id__get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1124,7 +1124,7 @@
"operationId": "get_current_api_v1_sites__site_id__current_get", "operationId": "get_current_api_v1_sites__site_id__current_get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1211,7 +1211,7 @@
"operationId": "list_alerts_api_v1_alerts_get", "operationId": "list_alerts_api_v1_alerts_get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1361,7 +1361,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -1375,7 +1375,7 @@
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get", "operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -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": [
{
"JetonAcces": []
}
],
"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": { "/api/v1/stats/summary": {
"get": { "get": {
"tags": [ "tags": [
@@ -1504,7 +1588,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -1518,7 +1602,7 @@
"operationId": "list_readings_api_v1_readings_get", "operationId": "list_readings_api_v1_readings_get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1715,7 +1799,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -1771,10 +1855,98 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
},
"/api/v1/monitoring/drift": {
"get": {
"tags": [
"monitoring"
],
"summary": "Dernier rapport de dérive par site, plus la ligne globale",
"operationId": "get_drift_api_v1_monitoring_drift_get",
"security": [
{
"JetonAcces": []
}
],
"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": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DriftReportResponse"
},
"title": "Response Get Drift Api V1 Monitoring Drift Get"
}
}
}
},
"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"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
@@ -1893,6 +2065,180 @@
], ],
"title": "AlertType" "title": "AlertType"
}, },
"DriftReportResponse": {
"properties": {
"site_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
},
"computed_at": {
"type": "string",
"format": "date-time",
"title": "Computed At"
},
"window_start": {
"type": "string",
"format": "date-time",
"title": "Window Start"
},
"window_end": {
"type": "string",
"format": "date-time",
"title": "Window End"
},
"reference_start": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Reference Start"
},
"reference_end": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Reference End"
},
"n_observations": {
"type": "integer",
"title": "N Observations"
},
"mae": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Mae"
},
"mape": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Mape"
},
"bias": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Bias"
},
"reference_mae": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Reference Mae"
},
"coverage_ratio": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Coverage Ratio"
},
"insufficient_data_ratio": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Insufficient Data Ratio"
},
"model_references": {
"items": {
"type": "string"
},
"type": "array",
"title": "Model References"
},
"status": {
"$ref": "#/components/schemas/DriftStatus"
},
"reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reason"
}
},
"type": "object",
"required": [
"site_id",
"computed_at",
"window_start",
"window_end",
"reference_start",
"reference_end",
"n_observations",
"mae",
"mape",
"bias",
"reference_mae",
"coverage_ratio",
"insufficient_data_ratio",
"model_references",
"status",
"reason"
],
"title": "DriftReportResponse"
},
"DriftStatus": {
"type": "string",
"enum": [
"stable",
"derive",
"indetermine"
],
"title": "DriftStatus"
},
"ErrorResponse": { "ErrorResponse": {
"properties": { "properties": {
"detail": { "detail": {
@@ -2344,6 +2690,29 @@
], ],
"title": "ReadingSource" "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": { "RecommendationResponse": {
"properties": { "properties": {
"recommendation_id": { "recommendation_id": {
@@ -3118,13 +3487,13 @@
} }
}, },
"securitySchemes": { "securitySchemes": {
"Cookie de rafraîchissement": { "CookieRafraichissement": {
"type": "apiKey", "type": "apiKey",
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.", "description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
"in": "cookie", "in": "cookie",
"name": "ev_refresh" "name": "ev_refresh"
}, },
"Jeton d'accès": { "JetonAcces": {
"type": "http", "type": "http",
"scheme": "bearer" "scheme": "bearer"
} }
@@ -3153,7 +3522,7 @@
}, },
{ {
"name": "recommendations", "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", "name": "stats",
@@ -3170,6 +3539,10 @@
{ {
"name": "predictions", "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`." "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`."
},
{
"name": "monitoring",
"description": "Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et les lectures réellement arrivées, par site et tous sites confondus. Réservé à partir du rôle `operateur`, qui agit sur un pipeline dégradé."
} }
] ]
} }
+5 -2
View File
@@ -87,8 +87,11 @@ disallow_untyped_defs = false
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing" addopts = "-q --strict-markers -m 'not integration and not chaine' --cov=app --cov-report=term-missing"
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"] markers = [
"integration: requiert une base PostgreSQL joignable, hors `make test`",
"chaine: requiert en plus l'environnement uv de ml/, hors `make test` et hors `-m integration`",
]
[tool.coverage.run] [tool.coverage.run]
source = ["app"] source = ["app"]
+2
View File
@@ -51,10 +51,12 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
("GET", "/api/v1/alerts"): Role.LECTEUR, ("GET", "/api/v1/alerts"): Role.LECTEUR,
("GET", "/api/v1/recommendations"): Role.LECTEUR, ("GET", "/api/v1/recommendations"): Role.LECTEUR,
("GET", "/api/v1/recommendations/{recommendation_id}"): 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/stats/summary"): Role.LECTEUR,
("GET", "/api/v1/readings"): Role.LECTEUR, ("GET", "/api/v1/readings"): Role.LECTEUR,
("GET", "/api/v1/predictions"): Role.LECTEUR, ("GET", "/api/v1/predictions"): Role.LECTEUR,
("GET", "/api/v1/sensors/status"): Role.ADMIN, ("GET", "/api/v1/sensors/status"): Role.ADMIN,
("GET", "/api/v1/monitoring/drift"): Role.OPERATEUR,
("GET", "/api/v1/users"): Role.ADMIN, ("GET", "/api/v1/users"): Role.ADMIN,
("POST", "/api/v1/users"): Role.ADMIN, ("POST", "/api/v1/users"): Role.ADMIN,
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN, ("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
+131
View File
@@ -0,0 +1,131 @@
"""Piège : ces fixtures valident leurs écritures, contrairement à celles de tests/repositories.
Un endpoint ouvre sa propre session par `get_session` : il ne verrait pas une ligne semée dans
une transaction en cours. Lui passer la session de la fixture par `dependency_overrides`
supprimerait justement ce que ces tests prouvent, et `RecommendationService.generate` valide de
toute façon lui-même. L'isolation vient donc de la marque portée par chaque `site_id`, et le
nettoyage est explicite, dans l'ordre imposé par les clés étrangères `RESTRICT`.
Contrainte : toutes ces fixtures sont à portée fonction. `engine_per_test` vide le cache du
moteur après chaque test ; une fixture de module verrait un moteur déjà fermé à son démontage,
et ses lignes resteraient en base.
"""
from collections.abc import AsyncIterator, Callable, Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from fastapi import FastAPI
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_principal
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.db.session import get_session_factory
from app.models.energy import Alert, Prediction, Reading, Recommendation, Site
from tests.repositories.test_alert import creer_alerte
from tests.repositories.test_prediction import creer_prediction
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
INSTANT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass(frozen=True)
class JeuMetier:
"""Identifiants seuls, jamais d'instance ORM : un attribut relu sur une session fermée
déclenche un `MissingGreenlet`."""
site_id: str
site_voisin: str
alert_id: int
prediction_id: int
instant: datetime
async def _supprime(session: AsyncSession, sites: list[str]) -> None:
# La suppression des recommandations est inconditionnelle : `POST /generate` en cree hors du
# controle de la fixture, et `alert` les retient par une cle etrangere `RESTRICT`.
alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites))
await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes)))
await session.execute(delete(Alert).where(Alert.site_id.in_(sites)))
await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites)))
await session.execute(delete(Reading).where(Reading.site_id.in_(sites)))
await session.execute(delete(Site).where(Site.site_id.in_(sites)))
await session.commit()
@pytest.fixture
def marque() -> str:
return uuid4().hex[:12]
@pytest.fixture
async def jeu_metier(marque: str) -> AsyncIterator[JeuMetier]:
"""Un site instrumenté, un site voisin, trois lectures horaires, une prédiction, une alerte.
Le voisin existe pour que les tests de filtre prouvent qu'ils écartent quelque chose.
"""
site_id = f"SITE-{marque}"
voisin = f"SITE-{marque}-VOISIN"
async with get_session_factory()() as session:
await creer_site(session, site_id=site_id, capacity_kw=100.0)
await creer_site(session, site_id=voisin, capacity_kw=100.0)
for decalage in range(3):
await creer_lecture(
session,
site_id=site_id,
timestamp=INSTANT - timedelta(hours=decalage),
consumption_kw=10.0 + decalage,
)
prediction = await creer_prediction(session, site_id=site_id, target_at=INSTANT)
alerte = await creer_alerte(session, site_id=site_id, timestamp=INSTANT)
jeu = JeuMetier(
site_id=site_id,
site_voisin=voisin,
alert_id=alerte.alert_id,
prediction_id=prediction.prediction_id,
instant=INSTANT,
)
await session.commit()
try:
yield jeu
finally:
async with get_session_factory()() as session:
await _supprime(session, [site_id, voisin])
@pytest.fixture
async def site_nu(marque: str) -> AsyncIterator[str]:
"""Un site sans lecture ni prédiction : le cas que seul un vrai `LEFT JOIN` distingue."""
site_id = f"SITE-{marque}-NU"
async with get_session_factory()() as session:
await creer_site(session, site_id=site_id, capacity_kw=100.0)
await session.commit()
try:
yield site_id
finally:
async with get_session_factory()() as session:
await _supprime(session, [site_id])
@pytest.fixture
def principal_injecte(app: FastAPI) -> Iterator[Callable[[Role], None]]:
def installe(role: Role = Role.LECTEUR) -> None:
app.dependency_overrides[get_current_principal] = lambda: Principal(
id=uuid4(),
email="parcours@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
yield installe
app.dependency_overrides.pop(get_current_principal, None)
+11 -9
View File
@@ -227,24 +227,26 @@ async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
assert ecarts == [] assert ecarts == []
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture # Contrainte : les deux rangs ne se séparent que sur les routes que `ROLE_MINIMUM` réserve à
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur # `operateur`. Une route d'opérateur ajoutée sans être classée fait diverger les statuts sans
# arrive sans que `ROLE_MINIMUM` soit mis à jour. # qu'aucune entrée ne l'annonce, et une garde d'opérateur posée par erreur sur une route de
# lecture fait diverger ce qui devait rester identique.
@pytest.mark.integration @pytest.mark.integration
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank( async def test_the_operator_rank_diverges_from_the_reader_rank_only_where_declared(
comptes_par_role: dict[Role, str], client: AsyncClient comptes_par_role: dict[Role, str], client: AsyncClient
) -> None: ) -> None:
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR]) lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR]) operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
divergences: list[tuple[str, str]] = [] ecarts: list[tuple[str, str]] = []
for methode, chemin in ROLE_MINIMUM: for (methode, chemin), minimum in ROLE_MINIMUM.items():
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur) cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
cote_operateur = await appelle(client, methode, chemin, headers=operateur) cote_operateur = await appelle(client, methode, chemin, headers=operateur)
if cote_lecteur.status_code != cote_operateur.status_code: diverge = cote_lecteur.status_code != cote_operateur.status_code
divergences.append((methode, chemin)) if diverge is not (minimum is Role.OPERATEUR):
ecarts.append((methode, chemin))
assert divergences == [] assert ecarts == []
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate # Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
+99
View File
@@ -0,0 +1,99 @@
from collections.abc import Iterator, Sequence
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_drift_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import DriftReport
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
def operateur() -> Principal:
return Principal(
id=uuid4(),
email="operateur@enervision.fr",
role=Role.OPERATEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def rapport(*, site_id: str | None) -> DriftReport:
return DriftReport(
drift_report_id=1,
computed_at=INSTANT,
site_id=site_id,
window_start=INSTANT - timedelta(hours=168),
window_end=INSTANT,
reference_start=None,
reference_end=None,
n_observations=48,
mae=1.5,
mape=12.0,
bias=0.3,
reference_mae=1.2,
coverage_ratio=0.95,
insufficient_data_ratio=0.0,
model_references=["lightgbm-aaa"],
status="stable",
reason=None,
)
class FauxService:
def __init__(self, rapports: Sequence[DriftReport]) -> None:
self.rapports = list(rapports)
self.site_demande: str | None = None
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
self.site_demande = site_id
return self.rapports
@pytest.fixture
def servi(app: FastAPI) -> Iterator[list[DriftReport]]:
rapports = [rapport(site_id="SITE001"), rapport(site_id=None)]
service = FauxService(rapports)
app.dependency_overrides[get_current_principal] = operateur
app.dependency_overrides[get_drift_service] = lambda: service
yield rapports
app.dependency_overrides.clear()
async def test_drift_returns_the_latest_report_of_every_site(
servi: list[DriftReport], client: AsyncClient
) -> None:
reponse = await client.get("/api/v1/monitoring/drift")
assert reponse.status_code == 200
assert [ligne["site_id"] for ligne in reponse.json()] == ["SITE001", None]
async def test_drift_exposes_the_metrics_of_the_stored_report(
servi: list[DriftReport], client: AsyncClient
) -> None:
reponse = await client.get("/api/v1/monitoring/drift")
premier = reponse.json()[0]
assert premier["status"] == "stable"
assert premier["mae"] == 1.5
assert premier["model_references"] == ["lightgbm-aaa"]
async def test_drift_returns_an_empty_list_when_no_report_exists(
app: FastAPI, client: AsyncClient
) -> None:
app.dependency_overrides[get_current_principal] = operateur
app.dependency_overrides[get_drift_service] = lambda: FauxService([])
reponse = await client.get("/api/v1/monitoring/drift")
assert reponse.status_code == 200
assert reponse.json() == []
app.dependency_overrides.clear()
+2 -2
View File
@@ -104,8 +104,8 @@ def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> No
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None: def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
schemes = schema["components"]["securitySchemes"] schemes = schema["components"]["securitySchemes"]
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie" assert schemes["CookieRafraichissement"]["in"] == "cookie"
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh" assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None: def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
@@ -0,0 +1,92 @@
from collections.abc import Callable
import pytest
from httpx import AsyncClient
from app.core.roles import Role
from tests.api.conftest import JeuMetier
pytestmark = pytest.mark.integration
async def genere(client: AsyncClient, site_id: str) -> dict[str, int]:
# Toujours borne a un site : sans `site_id`, le service examine toutes les alertes de la
# base, y compris celles d'un autre test, et le rapport cesse d'etre deterministe.
reponse = await client.post(f"/api/v1/recommendations/generate?site_id={site_id}")
assert reponse.status_code == 200
return dict(reponse.json())
async def test_generate_creates_a_recommendation_for_the_alert_of_the_requested_site(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
rapport = await genere(client, jeu_metier.site_id)
assert rapport["alerts_examined"] == 1
assert rapport["recommendations_created"] >= 1
assert rapport["already_present"] == 0
async def test_generate_creates_nothing_more_when_it_runs_twice_on_the_same_alerts(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
premier = await genere(client, jeu_metier.site_id)
second = await genere(client, jeu_metier.site_id)
assert second["recommendations_created"] == 0
assert second["already_present"] == premier["recommendations_created"]
async def test_generate_examines_no_alert_when_the_requested_site_has_none(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
rapport = await genere(client, jeu_metier.site_voisin)
assert rapport["alerts_examined"] == 0
assert rapport["recommendations_created"] == 0
async def test_list_recommendations_returns_what_generate_persisted_in_another_session(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
await genere(client, jeu_metier.site_id)
reponse = await client.get("/api/v1/recommendations")
assert reponse.status_code == 200
miennes = [r for r in reponse.json() if r["alert_id"] == jeu_metier.alert_id]
assert miennes != []
assert all(r["rule_reference"] for r in miennes)
async def test_get_recommendation_returns_the_row_created_by_generate(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
await genere(client, jeu_metier.site_id)
liste = await client.get("/api/v1/recommendations")
creee = next(r for r in liste.json() if r["alert_id"] == jeu_metier.alert_id)
reponse = await client.get(f"/api/v1/recommendations/{creee['recommendation_id']}")
assert reponse.status_code == 200
assert reponse.json() == creee
async def test_get_recommendation_returns_404_when_the_identifier_is_unknown(
principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/recommendations/9999999")
assert reponse.status_code == 404
assert reponse.json()["detail"] == "Recommandation introuvable"
@@ -0,0 +1,80 @@
from collections.abc import Callable
import pytest
from httpx import AsyncClient
from app.core.roles import Role
from app.db.session import get_session_factory
from tests.api.conftest import JeuMetier
from tests.repositories.test_reading import creer_lecture
pytestmark = pytest.mark.integration
async def test_list_sites_returns_the_seeded_site_with_its_stored_attributes(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/sites")
assert reponse.status_code == 200
mien = next(site for site in reponse.json() if site["site_id"] == jeu_metier.site_id)
assert mien["capacity_kw"] == 100.0
assert mien["site_name"] == "Site de test"
async def test_get_site_returns_404_when_the_identifier_is_absent_from_the_database(
principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/sites/SITE-JAMAIS-INSERE")
assert reponse.status_code == 404
assert reponse.json()["detail"] == "Site introuvable"
async def test_get_current_returns_the_most_recent_reading_when_several_hours_are_stored(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
assert reponse.status_code == 200
corps = reponse.json()
assert corps["consumption_kw"] == 10.0
assert corps["timestamp"].startswith("2026-09-16T12:00")
async def test_get_current_keeps_the_highest_reading_id_when_two_sources_share_the_timestamp(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
async with get_session_factory()() as session:
await creer_lecture(
session,
site_id=jeu_metier.site_id,
timestamp=jeu_metier.instant,
source="api_history",
consumption_kw=999.0,
)
await session.commit()
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
assert reponse.json()["consumption_kw"] == 999.0
async def test_get_current_reports_a_critical_quality_when_the_site_has_no_reading(
site_nu: str, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get(f"/api/v1/sites/{site_nu}/current")
assert reponse.status_code == 200
corps = reponse.json()
assert corps["timestamp"] is None
assert corps["data_quality"] == "critical"
+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.principal import Principal
from app.core.roles import AccountKind, Role from app.core.roles import AccountKind, Role
from app.models.energy import Recommendation 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) MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
@@ -40,6 +40,7 @@ class FauxService:
def __init__(self, erreur: Exception | None = None) -> None: def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur self._erreur = erreur
self.recommendation = recommendation() self.recommendation = recommendation()
self.site_demande: str | None = None
async def list_all(self) -> list[Recommendation]: async def list_all(self) -> list[Recommendation]:
return [self.recommendation] return [self.recommendation]
@@ -49,6 +50,10 @@ class FauxService:
raise self._erreur raise self._erreur
return self.recommendation 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 @pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]: 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") response = await client.get("/api/v1/recommendations/404")
assert response.status_code == 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
+89 -12
View File
@@ -1,5 +1,5 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -9,7 +9,15 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings from app.core.config import get_settings
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.energy import (
Alert,
Dataset,
DriftReport,
Prediction,
Reading,
Recommendation,
Site,
)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
MOMENT = datetime(2024, 1, 1, tzinfo=UTC) MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
@@ -112,8 +120,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
) )
await data_connection.execute(statement) await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
async with data_connection.begin_nested(): async with savepoint:
await data_connection.execute(statement) await data_connection.execute(statement)
@@ -147,9 +157,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
} }
values.update(changes) values.update(changes)
statement = insert(Reading).values(**values)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
async with data_connection.begin_nested(): async with savepoint:
await data_connection.execute(insert(Reading).values(**values)) await data_connection.execute(statement)
async def test_prediction_requires_period_when_energy_is_predicted( async def test_prediction_requires_period_when_energy_is_predicted(
@@ -164,8 +177,10 @@ async def test_prediction_requires_period_when_energy_is_predicted(
model_reference="test-model/1", model_reference="test-model/1",
) )
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
async with data_connection.begin_nested(): async with savepoint:
await data_connection.execute(statement) await data_connection.execute(statement)
@@ -212,10 +227,7 @@ async def test_alert_rejects_prediction_when_site_differs(
) )
).scalar_one() ).scalar_one()
with pytest.raises(IntegrityError): statement = insert(Alert).values(
async with data_connection.begin_nested():
await data_connection.execute(
insert(Alert).values(
source_alert_id=str(uuid4()), source_alert_id=str(uuid4()),
site_id=other_site, site_id=other_site,
source="enervision", source="enervision",
@@ -226,7 +238,11 @@ async def test_alert_rejects_prediction_when_site_differs(
prediction_id=prediction_id, prediction_id=prediction_id,
raw_data={}, raw_data={},
) )
) savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async def test_recommendation_is_unique_when_alert_and_rule_match( async def test_recommendation_is_unique_when_alert_and_rule_match(
@@ -256,6 +272,67 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
) )
await data_connection.execute(statement) await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
async with data_connection.begin_nested(): async with savepoint:
await data_connection.execute(statement)
def _rapport(**remplacements: object) -> dict[str, object]:
defauts: dict[str, object] = {
"site_id": None,
"window_start": MOMENT,
"window_end": MOMENT,
"n_observations": 12,
"model_references": ["lightgbm-aaa"],
"status": "stable",
"reason": None,
}
return {**defauts, **remplacements}
async def test_drift_report_rejects_an_unknown_status(data_connection: AsyncConnection) -> None:
statement = insert(DriftReport).values(**_rapport(status="douteux", reason="x"))
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async def test_drift_report_rejects_a_drift_without_a_reason(
data_connection: AsyncConnection,
) -> None:
statement = insert(DriftReport).values(**_rapport(status="derive"))
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async def test_drift_report_accepts_one_global_row_without_a_site(
data_connection: AsyncConnection,
) -> None:
identifiant = (
await data_connection.execute(
insert(DriftReport).values(**_rapport()).returning(DriftReport.drift_report_id)
)
).scalar_one()
assert identifiant is not None
async def test_drift_report_is_unique_when_window_and_site_match(
data_connection: AsyncConnection,
) -> None:
fenetre = MOMENT + timedelta(days=1)
statement = insert(DriftReport).values(**_rapport(window_end=fenetre))
await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement) await data_connection.execute(statement)
@@ -106,13 +106,15 @@ def test_validate_source_accepts_valid_dataset():
def test_validate_source_rejects_missing_column(): def test_validate_source_rejects_missing_column():
frame = make_dataframe().drop(columns=["consumption_kwh"]) frame = make_dataframe().drop(columns=["consumption_kwh"])
metadata = make_metadata()
with pytest.raises( with pytest.raises(
ValueError, ValueError,
match="Colonnes obligatoires absentes", match="Colonnes obligatoires absentes",
): ):
validate_source( validate_source(
frame, frame,
make_metadata(), metadata,
) )
@@ -124,13 +126,15 @@ def test_validate_source_rejects_duplicates():
"timestamp", "timestamp",
] ]
metadata = make_metadata()
with pytest.raises( with pytest.raises(
ValueError, ValueError,
match="doublons", match="doublons",
): ):
validate_source( validate_source(
frame, frame,
make_metadata(), metadata,
) )
@@ -139,13 +143,15 @@ def test_validate_source_rejects_unknown_site():
frame.loc[1, "site_id"] = "SITE999" frame.loc[1, "site_id"] = "SITE999"
metadata = make_metadata()
with pytest.raises( with pytest.raises(
ValueError, ValueError,
match="Sites incohérents", match="Sites incohérents",
): ):
validate_source( validate_source(
frame, frame,
make_metadata(), metadata,
) )
@@ -13,10 +13,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
import app.etl.mock_api_import as mock_api_import import app.etl.mock_api_import as mock_api_import
from app.etl.mock_api_import import ( from app.etl.mock_api_import import (
MAX_SITES,
READING_INSERT, READING_INSERT,
SOURCE_HISTORY, SOURCE_HISTORY,
build_reading_batch, build_reading_batch,
build_reading_row, build_reading_row,
build_site_row,
fetch_readings, fetch_readings,
fetch_sites, fetch_sites,
upsert_sites, upsert_sites,
@@ -73,6 +75,26 @@ async def test_fetch_sites_returns_sites() -> None:
assert sites[0]["site_type"] == "office" 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: async def test_fetch_readings_sends_expected_query_parameters() -> None:
captured_params: dict[str, str] = {} captured_params: dict[str, str] = {}
@@ -261,6 +283,41 @@ def test_create_mock_api_client_requires_credentials(
mock_api_import.create_mock_api_client() mock_api_import.create_mock_api_client()
@pytest.mark.parametrize(
("username", "password_value"),
[
("", "test-password"),
("test-user", ""),
(" ", "test-password"),
("test-user", " "),
],
)
def test_create_mock_api_client_rejects_empty_credentials(
monkeypatch: pytest.MonkeyPatch,
username: str,
password_value: str,
) -> None:
password = MagicMock()
password.get_secret_value.return_value = password_value
settings = SimpleNamespace(
mock_api_username=username,
mock_api_password=password,
)
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( async def test_create_mock_api_client_uses_configuration(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -576,6 +633,148 @@ def test_main_runs_import(
) )
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 @pytest.mark.integration
async def test_reading_insert_is_idempotent( async def test_reading_insert_is_idempotent(
session: AsyncSession, session: AsyncSession,
@@ -620,3 +819,51 @@ async def test_reading_insert_is_idempotent(
assert result.scalar_one() == 1 assert result.scalar_one() == 1
await session.rollback() 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"
@@ -5,6 +5,7 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert from app.models.energy import Alert
from app.repositories import alert as module_alert
from app.repositories.alert import AlertRepository from app.repositories.alert import AlertRepository
from app.schemas.alert import AlertSeverity from app.schemas.alert import AlertSeverity
from tests.repositories.test_site import creer as creer_site from tests.repositories.test_site import creer as creer_site
@@ -146,3 +147,20 @@ async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession)
creees = await depot.create_many([]) creees = await depot.create_many([])
assert creees == [] assert creees == []
async def test_create_many_inserts_every_alert_across_several_batches(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(module_alert, "TAILLE_DE_LOT", 2)
site = await creer_site(session)
depot = AlertRepository(session)
a_inserer = [
_alerte_a_inserer(site_id=site.site_id, source_alert_id=f"threshold:lot-{index}")
for index in range(5)
]
creees = await depot.create_many(a_inserer)
await session.rollback()
assert len(creees) == 5
@@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
) -> None: ) -> None:
await une_ligne(session) await une_ligne(session)
requete = text(instruction)
with pytest.raises(DBAPIError, match="ajout seul"): with pytest.raises(DBAPIError, match="ajout seul"):
await session.execute(text(instruction)) await session.execute(requete)
await session.rollback() await session.rollback()
@@ -0,0 +1,187 @@
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import ClauseElement
from app.repositories.drift import (
DriftRepository,
NouveauRapportDerive,
_lectures_retenues,
_predictions_retenues,
)
from tests.repositories.test_prediction import creer_prediction
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
DEBUT = datetime(2026, 9, 15, tzinfo=UTC)
FIN = datetime(2026, 9, 22, tzinfo=UTC)
CIBLE = datetime(2026, 9, 16, 12, tzinfo=UTC)
def sql(requete: ClauseElement) -> str:
return str(requete.compile(dialect=postgresql.dialect())) # type: ignore[no-untyped-call]
def rapport(**remplacements: object) -> NouveauRapportDerive:
defauts: dict[str, object] = {
"site_id": None,
"window_start": DEBUT,
"window_end": FIN,
"reference_start": None,
"reference_end": None,
"n_observations": 10,
"mae": 1.0,
"mape": 5.0,
"bias": 0.1,
"reference_mae": None,
"coverage_ratio": 1.0,
"insufficient_data_ratio": 0.0,
"model_references": ["lightgbm-aaa"],
"status": "stable",
"reason": None,
}
return NouveauRapportDerive(**{**defauts, **remplacements}) # type: ignore[arg-type]
def test_predictions_keep_one_row_per_site_and_target_in_sql() -> None:
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "DISTINCT ON (prediction.site_id, prediction.target_at)" in requete
assert "prediction.prediction_id DESC" in requete
def test_readings_keep_one_row_per_site_and_instant_in_sql() -> None:
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "DISTINCT ON (reading.site_id, reading.timestamp)" in requete
assert "reading.reading_id DESC" in requete
def test_predictions_restrict_themselves_to_the_requested_site_in_sql() -> None:
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id="SITE001").element)
assert requete.count("prediction.site_id = ") == 1
def test_readings_ignore_a_missing_consumption_in_sql() -> None:
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "reading.consumption_kwh IS NOT NULL" in requete
@pytest.mark.integration
async def test_repository_pairs_a_prediction_with_the_reading_of_the_same_instant(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [(p.predicted_value, p.actual_value) for p in paires] == [(12.0, 10.0)]
@pytest.mark.integration
async def test_repository_keeps_the_latest_run_when_several_predictions_share_a_target(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=99.0)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [p.predicted_value for p in paires] == [99.0]
@pytest.mark.integration
async def test_repository_keeps_one_reading_per_instant_when_two_sources_wrote_the_same_hour(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_lecture(
session, site_id=site.site_id, timestamp=CIBLE, source="api_current", consumption_kwh=10.0
)
await creer_lecture(
session, site_id=site.site_id, timestamp=CIBLE, source="api_history", consumption_kwh=20.0
)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [p.actual_value for p in paires] == [20.0]
@pytest.mark.integration
async def test_repository_excludes_an_insufficient_data_prediction_from_the_pairs(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(
session,
site_id=site.site_id,
target_at=CIBLE,
predicted_value=None,
status="insufficient_data",
failure_reason="historique trop court",
)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
depot = DriftRepository(session)
paires = await depot.paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
comptages = await depot.comptages(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert paires == []
assert [(c.status, c.nombre) for c in comptages] == [("insufficient_data", 1)]
@pytest.mark.integration
async def test_repository_excludes_a_target_outside_the_window(session: AsyncSession) -> None:
site = await creer_site(session)
hors_fenetre = FIN + timedelta(hours=1)
await creer_prediction(
session, site_id=site.site_id, target_at=hors_fenetre, predicted_value=12.0
)
await creer_lecture(session, site_id=site.site_id, timestamp=hors_fenetre, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert paires == []
@pytest.mark.integration
async def test_repository_reads_back_the_global_report_it_wrote(session: AsyncSession) -> None:
depot = DriftRepository(session)
fenetre = datetime(2035, 3, 1, tzinfo=UTC)
ecrites = await depot.enregistre([rapport(window_end=fenetre)])
derniers = await depot.derniers()
globaux = [r for r in derniers if r.site_id is None and r.window_end == fenetre]
await session.rollback()
assert ecrites == 1
assert len(globaux) == 1
@pytest.mark.integration
async def test_repository_ignores_a_second_report_for_the_same_window_and_site(
session: AsyncSession,
) -> None:
depot = DriftRepository(session)
fenetre = datetime(2035, 4, 1, tzinfo=UTC)
premiere = await depot.enregistre([rapport(window_end=fenetre)])
seconde = await depot.enregistre([rapport(window_end=fenetre, status="derive", reason="x")])
await session.rollback()
assert premiere == 1
assert seconde == 0
@@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
user_agent=None, user_agent=None,
) )
empreinte = fingerprint_refresh(secret)
expiration = datetime.now(UTC) + DUREE
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
await depot.create( await depot.create(
user_id=compte, user_id=compte,
token_hash=fingerprint_refresh(secret), token_hash=empreinte,
expires_at=datetime.now(UTC) + DUREE, expires_at=expiration,
client_ip=None, client_ip=None,
user_agent=None, user_agent=None,
) )
@@ -33,6 +33,9 @@ async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: obj
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)), timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
source=overrides.get("source", "api_current"), source=overrides.get("source", "api_current"),
consumption_kw=overrides.get("consumption_kw", 10.0), consumption_kw=overrides.get("consumption_kw", 10.0),
# Nul par defaut : seules les mesures en kWh alimentent la comparaison prevu/realise, et
# un override silencieusement ignore laissait la colonne vide sans que rien ne le dise.
consumption_kwh=overrides.get("consumption_kwh"),
data_quality=overrides.get("data_quality", "good"), data_quality=overrides.get("data_quality", "good"),
raw_data=overrides.get("raw_data", {}), raw_data=overrides.get("raw_data", {}),
) )
@@ -5,7 +5,8 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert, Recommendation, Site 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 pytestmark = pytest.mark.integration
@@ -83,3 +84,59 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier(
await session.rollback() await session.rollback()
assert identifiants == sorted(identifiants) 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
@@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
user_agent=None, user_agent=None,
) )
famille = uuid.uuid4()
empreinte = fingerprint_refresh(secret)
expiration = datetime.now(UTC) + DUREE
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
await depot.create( await depot.create(
user_id=compte, user_id=compte,
family_id=uuid.uuid4(), family_id=famille,
token_hash=fingerprint_refresh(secret), token_hash=empreinte,
expires_at=datetime.now(UTC) + DUREE, expires_at=expiration,
client_ip=None, client_ip=None,
user_agent=None, user_agent=None,
) )
+5 -7
View File
@@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
) -> None: ) -> None:
saisie = adresse().upper() saisie = adresse().upper()
with pytest.raises(IntegrityError): requete = text(
await session.execute( "insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
text(
"insert into app_user (email, password_hash, role) "
"values (:e, '$argon2id$x', 'lecteur')"
),
{"e": saisie},
) )
with pytest.raises(IntegrityError):
await session.execute(requete, {"e": saisie})
await session.rollback() await session.rollback()
+271
View File
@@ -0,0 +1,271 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
import pytest
from app.repositories.drift import ComptageStatut, PaireDerive
from app.services.drift import (
STATUT_DERIVE,
STATUT_INDETERMINE,
STATUT_STABLE,
DriftService,
Seuils,
mesure,
)
INSTANT = datetime(2026, 9, 22, 12, 0, tzinfo=UTC)
def paire(
*, site_id: str = "SITE001", prevu: float, reel: float, reference: str = "lightgbm-aaa"
) -> PaireDerive:
return PaireDerive(
site_id=site_id,
target_at=INSTANT,
predicted_value=prevu,
actual_value=reel,
model_reference=reference,
)
def paires(
*, site_id: str = "SITE001", nombre: int, prevu: float, reel: float
) -> list[PaireDerive]:
return [paire(site_id=site_id, prevu=prevu, reel=reel) for _ in range(nombre)]
class FauxDepot:
def __init__(
self,
*,
recentes: Sequence[PaireDerive] = (),
anciennes: Sequence[PaireDerive] = (),
comptages: Sequence[ComptageStatut] = (),
) -> None:
self.recentes = list(recentes)
self.anciennes = list(anciennes)
self._comptages = list(comptages)
self.fenetres: list[tuple[datetime, datetime]] = []
async def paires(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[PaireDerive]:
self.fenetres.append((debut, fin))
return self.recentes if len(self.fenetres) == 1 else self.anciennes
async def comptages(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[ComptageStatut]:
return self._comptages
def service(depot: FauxDepot, **surcharges: object) -> DriftService:
return DriftService(depot, seuils=Seuils(**surcharges)) # type: ignore[arg-type]
def test_drift_averages_the_absolute_gap_between_forecast_and_actual() -> None:
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=8.0, reel=10.0)])
assert metriques.mae == 2.0
assert metriques.n_observations == 2
def test_drift_computes_a_signed_bias_when_the_model_overforecasts() -> None:
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=14.0, reel=10.0)])
assert metriques.bias == 3.0
def test_drift_computes_a_negative_bias_when_the_model_underforecasts() -> None:
metriques = mesure([paire(prevu=8.0, reel=10.0), paire(prevu=6.0, reel=10.0)])
assert metriques.bias == -3.0
def test_drift_excludes_a_zero_actual_from_the_mape_only() -> None:
metriques = mesure([paire(prevu=11.0, reel=10.0), paire(prevu=5.0, reel=0.0)])
assert metriques.mape == 10.0
assert metriques.n_observations == 2
assert metriques.mae == 3.0
def test_drift_reports_no_mape_when_every_actual_is_zero() -> None:
metriques = mesure([paire(prevu=1.0, reel=0.0)])
assert metriques.mape is None
def test_drift_lists_every_model_reference_seen_in_the_window() -> None:
metriques = mesure(
[paire(prevu=10.0, reel=10.0, reference="lightgbm-bbb"), paire(prevu=10.0, reel=10.0)]
)
assert metriques.model_references == ["lightgbm-aaa", "lightgbm-bbb"]
async def test_drift_reports_indetermine_when_the_window_holds_too_few_observations() -> None:
depot = FauxDepot(recentes=paires(nombre=3, prevu=10.0, reel=10.0))
rapports = await service(depot, min_observations=24).evaluate(now=INSTANT)
assert {rapport.status for rapport in rapports} == {STATUT_INDETERMINE}
assert all(rapport.reason for rapport in rapports)
async def test_drift_reports_derive_when_the_recent_mae_exceeds_the_reference_ratio() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_DERIVE
assert global_.mae == 4.0
assert global_.reference_mae == 1.0
async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_reference() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=11.0, reel=10.0),
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_STABLE
assert global_.reason is None
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=100)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_DERIVE
assert global_.coverage_ratio == 0.3
async def test_drift_reports_one_line_per_site_and_one_global_line() -> None:
depot = FauxDepot(
recentes=[
*paires(site_id="SITE001", nombre=12, prevu=10.0, reel=10.0),
*paires(site_id="SITE002", nombre=12, prevu=10.0, reel=10.0),
],
comptages=[
ComptageStatut(site_id="SITE001", status="available", nombre=12),
ComptageStatut(site_id="SITE002", status="available", nombre=12),
],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
assert [rapport.site_id for rapport in rapports] == ["SITE001", "SITE002", None]
assert next(r for r in rapports if r.site_id is None).n_observations == 24
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[
ComptageStatut(site_id="SITE001", status="available", nombre=30),
ComptageStatut(site_id="SITE001", status="insufficient_data", nombre=10),
],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
assert next(r for r in rapports if r.site_id is None).insufficient_data_ratio == 0.25
async def test_drift_closes_the_window_before_the_grace_delay() -> None:
depot = FauxDepot()
await service(depot, grace=timedelta(hours=2), fenetre=timedelta(hours=168)).evaluate(
now=INSTANT
)
recente, reference = depot.fenetres
assert recente[1] == INSTANT - timedelta(hours=2)
assert recente[0] == INSTANT - timedelta(hours=170)
assert reference[1] == recente[0]
@pytest.mark.parametrize(
("prevu", "attendu"),
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
ids=["mae_stable", "mae_triplee"],
)
async def test_drift_compares_the_recent_window_to_the_reference_one(
prevu: float, attendu: str
) -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10, mae_plancher=1.0).evaluate(now=INSTANT)
assert next(r for r in rapports if r.site_id is None).status == attendu
async def test_drift_leaves_the_bias_out_of_the_verdict_by_default() -> None:
# Le modèle surestime de 3 kWh à chaque heure, et le verdict reste `stable` : le biais est
# mesuré et servi, il ne juge pas tant que `--bias-threshold` n'a pas été réglé (ADR 0013).
depot = FauxDepot(
recentes=paires(nombre=30, prevu=13.0, reel=10.0),
anciennes=paires(nombre=30, prevu=13.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_STABLE
assert global_.bias == 3.0
@pytest.mark.parametrize(
("prevu", "attendu"),
[(13.0, STATUT_DERIVE), (11.0, STATUT_STABLE)],
ids=["biais_au_dela", "biais_sous_le_seuil"],
)
async def test_drift_reports_derive_on_the_bias_once_a_threshold_is_set(
prevu: float, attendu: str
) -> None:
# MAE récente et MAE de référence sont égales : seul le biais peut faire basculer le verdict.
depot = FauxDepot(
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
anciennes=paires(nombre=30, prevu=prevu, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10, seuil_biais=2.0).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == attendu
async def test_drift_prefers_the_mae_reason_when_both_the_mae_and_the_bias_exceed() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=20.0, reel=10.0),
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10, seuil_biais=2.0).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_DERIVE
assert "MAE" in (global_.reason or "")
+4 -6
View File
@@ -116,13 +116,11 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
async def test_list_history_raises_when_start_is_after_end() -> None: async def test_list_history_raises_when_start_is_after_end() -> None:
service = ReadingService(readings=FakeRepository([])) service = ReadingService(readings=FakeRepository([]))
debut = datetime(2026, 9, 2, tzinfo=UTC)
fin = datetime(2026, 9, 1, tzinfo=UTC)
with pytest.raises(FenetreInverseeError): with pytest.raises(FenetreInverseeError):
await service.list_history( await service.list_history(start=debut, end=fin, limit=500, offset=0)
start=datetime(2026, 9, 2, tzinfo=UTC),
end=datetime(2026, 9, 1, tzinfo=UTC),
limit=500,
offset=0,
)
async def test_list_history_raises_when_start_equals_end() -> None: async def test_list_history_raises_when_start_equals_end() -> None:
@@ -1,10 +1,14 @@
from collections.abc import Sequence
from datetime import UTC, datetime from datetime import UTC, datetime
import pytest 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 from app.services.recommendation import RecommendationNotFoundError, RecommendationService
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
def recommendation(recommendation_id: int = 1) -> Recommendation: def recommendation(recommendation_id: int = 1) -> Recommendation:
return Recommendation( return Recommendation(
@@ -13,13 +17,33 @@ def recommendation(recommendation_id: int = 1) -> Recommendation:
action="Vérifier la consommation", action="Vérifier la consommation",
explanation="Pic détecté", explanation="Pic détecté",
rule_reference="spike-v1", 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: class FakeRepository:
def __init__(self, recommendations: list[Recommendation]) -> None: def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None:
self._recommendations = recommendations self._recommendations = recommendations
self._creees = creees
self.recues: list[NouvelleRecommandation] = []
async def list_all(self) -> list[Recommendation]: async def list_all(self) -> list[Recommendation]:
return self._recommendations return self._recommendations
@@ -29,27 +53,111 @@ class FakeRepository:
(r for r in self._recommendations if r.recommendation_id == recommendation_id), None (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( class FakeAlertRepository:
recommendations=FakeRepository([recommendation(1), recommendation(2)]) 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] assert [r.recommendation_id for r in recommendations] == [1, 2]
async def test_get_by_id_returns_the_matching_recommendation() -> None: async def test_get_by_id_returns_the_matching_recommendation() -> None:
service = RecommendationService(recommendations=FakeRepository([recommendation(1)])) trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1)
trouve = await service.get_by_id(1)
assert trouve.recommendation_id == 1 assert trouve.recommendation_id == 1
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None: async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
service = RecommendationService(recommendations=FakeRepository([]))
with pytest.raises(RecommendationNotFoundError): 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",
}
+3 -1
View File
@@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
if action == "set_active": if action == "set_active":
arguments["is_active"] = False arguments["is_active"] = False
methode = getattr(attirail.service, action)
with pytest.raises(UserNotFoundError): with pytest.raises(UserNotFoundError):
await getattr(attirail.service, action)(**arguments) await methode(**arguments)
+223
View File
@@ -0,0 +1,223 @@
"""Piege : ce fichier porte le marqueur `chaine`, pas `integration` - test_the_ml_binaries...()
Il lance les vrais binaires `enervision_ml.train` et `enervision_ml.score` dans l'environnement
uv de `ml/`, que le job `integration` de `backend.yml` n'installe pas. Un marqueur distinct evite
que ce job, et `make test`, ne le selectionnent et n'echouent faute de `ml/.venv`.
"""
import math
import os
import subprocess
from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from functools import partial
from pathlib import Path
from typing import Any
from uuid import uuid4
import anyio
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from sqlalchemy import delete, insert, make_url
from app.api.deps import get_current_principal
from app.core.config import get_settings
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.db.session import get_session_factory
from app.models.energy import Prediction, Reading, Site
pytestmark = pytest.mark.chaine
RACINE = Path(__file__).resolve().parents[3]
ML = RACINE / "ml"
PYTHON_ML = Path(os.environ.get("ML_PYTHON", ML / ".venv" / "bin" / "python"))
HEURES_COMPLETES = 400
HEURES_INSUFFISANTES = 100
def lecteur() -> Principal:
return Principal(
id=uuid4(),
email="lecteur@enervision.fr",
role=Role.LECTEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def url_ml() -> str:
"""Derive la chaine du pipeline de celle du backend plutot que de la recopier : les deux
cotes visent ainsi la meme base, dans leur dialecte respectif."""
return (
make_url(get_settings().database_url)
.set(drivername="postgresql+psycopg")
.render_as_string(hide_password=False)
)
def lance_ml(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
if not PYTHON_ML.exists():
pytest.fail(
f"Environnement ml/ absent ({PYTHON_ML}). Lancer `cd ml && uv sync --all-groups`."
)
return subprocess.run( # noqa: S603 -- argv en liste, sans shell, binaire resolu dans le depot
[str(PYTHON_ML), "-m", module, *arguments],
cwd=ML,
text=True,
capture_output=True,
timeout=600,
check=False,
env={
**os.environ,
"ML_DATABASE_URL": url_ml(),
"MLFLOW_TRACKING_URI": f"sqlite:///{journal}/mlflow.db",
},
)
async def executer(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
resultat = await anyio.to_thread.run_sync(
partial(lance_ml, module, *arguments, journal=journal)
)
assert resultat.returncode == 0, resultat.stderr
return resultat
@dataclass
class Parc:
sites: list[str] = field(default_factory=list)
def lignes_horaires(site_id: str, *, heures: int, fin: datetime) -> list[dict[str, Any]]:
return [
{
"site_id": site_id,
"timestamp": fin - timedelta(hours=decalage),
"source": "api_history",
"consumption_kwh": 50.0 + math.sin(decalage / 12.0) * 10.0,
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": True,
"raw_data": {},
}
for decalage in reversed(range(heures))
]
@pytest.fixture
async def parc() -> AsyncIterator[Parc]:
"""Deux sites dotes d'un historique complet, un troisieme qui n'atteint pas le lag de 168 h.
Les ecritures sont validees : les binaires ML ouvrent leur propre connexion et ne verraient
pas une transaction en cours.
"""
fin = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
marque = uuid4().hex[:12]
complets = [f"TEST-{marque}-A", f"TEST-{marque}-B"]
partiel = f"TEST-{marque}-C"
parc = Parc(sites=[*complets, partiel])
async with get_session_factory()() as session:
await session.execute(
insert(Site),
[
{
"site_id": site_id,
"site_name": f"Site {site_id}",
"site_type": "office",
"capacity_kw": 100.0,
}
for site_id in parc.sites
],
)
for site_id in complets:
await session.execute(
insert(Reading), lignes_horaires(site_id, heures=HEURES_COMPLETES, fin=fin)
)
await session.execute(
insert(Reading), lignes_horaires(partiel, heures=HEURES_INSUFFISANTES, fin=fin)
)
await session.commit()
try:
yield parc
finally:
async with get_session_factory()() as session:
await session.execute(delete(Prediction).where(Prediction.site_id.in_(parc.sites)))
await session.execute(delete(Reading).where(Reading.site_id.in_(parc.sites)))
await session.execute(delete(Site).where(Site.site_id.in_(parc.sites)))
await session.commit()
@pytest.fixture
def principal_lecteur(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lecteur
yield
app.dependency_overrides.pop(get_current_principal, None)
async def resume_du_site(client: AsyncClient, site_id: str) -> dict[str, Any]:
reponse = await client.get("/api/v1/predictions")
assert reponse.status_code == 200
sites = reponse.json()["sites"]
return next(site for site in sites if site["site_id"] == site_id)
async def entraine_et_score(parc: Parc, tmp_path: Path, *arguments: str) -> Path:
modele = tmp_path / "lightgbm-consumption.txt"
await executer(
"enervision_ml.train",
"--model-output",
str(modele),
"--mlflow-tracking-uri",
f"sqlite:///{tmp_path}/mlflow.db",
journal=tmp_path,
)
await executer("enervision_ml.score", "--model", str(modele), *arguments, journal=tmp_path)
return modele
async def test_the_ml_binaries_produce_a_prediction_that_the_api_serves(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
await entraine_et_score(parc, tmp_path)
servi = await resume_du_site(client, parc.sites[0])
assert servi["prediction"]["status"] == "available"
assert servi["prediction"]["predicted_value"] is not None
assert servi["prediction"]["target_metric"] == "consumption_kwh"
async def test_the_api_exposes_the_failure_reason_of_a_site_without_enough_history(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
await entraine_et_score(parc, tmp_path)
servi = await resume_du_site(client, parc.sites[-1])
assert servi["prediction"]["status"] == "insufficient_data"
assert servi["prediction"]["predicted_value"] is None
assert servi["prediction"]["failure_reason"] is not None
async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
modele = await entraine_et_score(parc, tmp_path)
premier = await resume_du_site(client, parc.sites[0])
await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path)
second = await resume_du_site(client, parc.sites[0])
assert second["prediction"]["created_at"] >= premier["prediction"]["created_at"]
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]
+33 -2
View File
@@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None:
def test_build_parser_requires_a_subcommand() -> None: def test_build_parser_requires_a_subcommand() -> None:
parser = cli.build_parser()
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
cli.build_parser().parse_args([]) parser.parse_args([])
def test_build_parser_requires_an_email() -> None: def test_build_parser_requires_an_email() -> None:
parser = cli.build_parser()
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
cli.build_parser().parse_args(["create-admin"]) parser.parse_args(["create-admin"])
def test_read_password_generates_a_long_secret_when_asked( def test_read_password_generates_a_long_secret_when_asked(
@@ -118,3 +122,30 @@ def test_main_exports_the_contract_without_asking_for_a_password(
assert code == 0 assert code == 0
assert destination.exists() assert destination.exists()
assert str(destination) in capsys.readouterr().out 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
+116
View File
@@ -0,0 +1,116 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.monitoring import drift as cli
from app.repositories.drift import NouveauRapportDerive
from app.services.drift import STATUT_DERIVE, STATUT_STABLE, Seuils
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
def rapport(*, site_id: str | None, status: str, reason: str | None = None) -> NouveauRapportDerive:
return NouveauRapportDerive(
site_id=site_id,
window_start=INSTANT - timedelta(hours=168),
window_end=INSTANT,
reference_start=None,
reference_end=None,
n_observations=48,
mae=1.5,
mape=12.0,
bias=0.3,
reference_mae=1.2,
coverage_ratio=1.0,
insufficient_data_ratio=0.0,
model_references=["lightgbm-aaa"],
status=status,
reason=reason,
)
def installe(monkeypatch: pytest.MonkeyPatch, rapports: list[NouveauRapportDerive]) -> None:
async def fausse_execution(
*, now: datetime | None, site_id: str | None, seuils: Seuils | None
) -> list[NouveauRapportDerive]:
return rapports
monkeypatch.setattr(cli, "run_drift", fausse_execution)
def test_parse_args_defaults_to_the_standard_window() -> None:
arguments = cli.parse_args([])
assert arguments.window_hours == 168
assert arguments.grace_hours == 2
assert arguments.fail_on_drift is False
def test_parse_args_reads_the_site_id() -> None:
assert cli.parse_args(["--site-id", "SITE001"]).site_id == "SITE001"
def test_parse_args_parses_the_instant_option() -> None:
arguments = cli.parse_args(["--now", "2026-09-22T12:00:00+00:00"])
assert arguments.now == INSTANT
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
assert cli._parse_instant("2026-09-22T12:00:00") == INSTANT
def test_seuils_depuis_translates_the_hour_options_into_durations() -> None:
seuils = cli.seuils_depuis(cli.parse_args(["--window-hours", "24", "--grace-hours", "1"]))
assert seuils.fenetre == timedelta(hours=24)
assert seuils.grace == timedelta(hours=1)
def test_main_prints_the_verdict_of_every_line(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(
monkeypatch,
[
rapport(site_id="SITE001", status=STATUT_STABLE),
rapport(site_id=None, status=STATUT_STABLE),
],
)
code = cli.main([])
sortie = capsys.readouterr().out
assert code == 0
assert "SITE001" in sortie
assert "TOUS SITES" in sortie
def test_main_exits_non_zero_when_drift_is_detected_and_the_flag_is_set(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
code = cli.main(["--fail-on-drift"])
assert code == 1
assert "MAE doublée" in capsys.readouterr().out
def test_main_exits_zero_when_drift_is_detected_without_the_flag(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
code = cli.main([])
assert code == 0
assert capsys.readouterr().out != ""
def test_parse_args_leaves_the_bias_threshold_disabled_by_default() -> None:
assert cli.parse_args([]).bias_threshold == 0.0
def test_seuils_depuis_carries_the_bias_threshold() -> None:
assert cli.seuils_depuis(cli.parse_args(["--bias-threshold", "2.5"])).seuil_biais == 2.5
+37 -37
View File
@@ -655,31 +655,31 @@ wheels = [
[[package]] [[package]]
name = "pandas" name = "pandas"
version = "3.0.5" version = "3.0.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, { url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, { url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, { url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, { url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, { url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, { url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, { url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, { url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, { url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, { url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, { url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, { url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, { url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, { url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, { url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, { url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
] ]
[[package]] [[package]]
@@ -936,27 +936,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.7" version = "0.16.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
] ]
[[package]] [[package]]
+14 -6
View File
@@ -3,7 +3,7 @@
# ================== # ==================
# Image pour frontend # Image pour frontend
FROM node:24-alpine3.22 AS builder FROM node:26-alpine3.22 AS builder
WORKDIR /app WORKDIR /app
@@ -23,23 +23,31 @@ RUN npm run build
# ================== # ==================
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner FROM nginx:1.31-alpine AS runner
# Copie de la configuration de nginx # Copie de la configuration de nginx
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf COPY --chown=root:root nginx.conf /etc/nginx/nginx.conf
RUN chmod 755 /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory # Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html COPY --chown=root:root --from=builder /app/dist/frontend/browser /usr/share/nginx/html
RUN chmod 755 /usr/share/nginx/html
# Create necessary directories with proper permissions for nginx # Create necessary directories with proper permissions for nginx
RUN mkdir -p /var/log/nginx /var/cache/nginx && \ RUN mkdir -p /var/log/nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html &&\
chmod 755 /var/log/nginx /var/log
# Permissions appropriées
RUN find /usr/share/nginx/html -type f -exec chmod 644 {} \; && \
find /usr/share/nginx/html -type d -exec chmod 755 {} \; && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx
# Use a non-root user for security best practices # Use a non-root user for security best practices
USER nginx USER nginx
# Frontend : port 3000 # Frontend : port 3000
# Backend : port 8000
EXPOSE 3000 EXPOSE 3000
# Start Nginx directly with custom config # Start Nginx directly with custom config
+4 -1
View File
@@ -1,4 +1,4 @@
# Conventions de tests unitaires — Frontend # Conventions de tests unitaires : Frontend
## Outil ## Outil
Vitest (intégré nativement à Angular CLI, pas d'installation à faire). Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
@@ -83,3 +83,6 @@ describe('MonComposant', () => {
## Lancer les tests ## Lancer les tests
- Développement (mode watch) : `npm test` - Développement (mode watch) : `npm test`
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html` - Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
- Un fichier ou un dossier seulement :
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
+5
View File
@@ -34,6 +34,11 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"optimization": {
"styles": {
"inlineCritical": false
}
},
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
+2 -3
View File
@@ -1,5 +1,5 @@
worker_processes auto; worker_processes auto;
error_log /var/log/nginx/error.log warn; error_log /dev/null warn; # ← Envoyer les logs vers /dev/null
pid /tmp/nginx.pid; pid /tmp/nginx.pid;
events { events {
@@ -9,15 +9,14 @@ events {
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
access_log /dev/null; # ← Idem pour access logs
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
server { server {
listen 3000; listen 3000;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
+94 -173
View File
@@ -8,12 +8,12 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@angular/common": "^22.1.0", "@angular/common": "^22.1.7",
"@angular/compiler": "^22.1.0", "@angular/compiler": "^22.1.7",
"@angular/core": "^22.1.0", "@angular/core": "^22.1.7",
"@angular/forms": "^22.1.0", "@angular/forms": "^22.1.7",
"@angular/platform-browser": "^22.1.0", "@angular/platform-browser": "^22.1.7",
"@angular/router": "^22.1.0", "@angular/router": "^22.1.7",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -21,21 +21,14 @@
"devDependencies": { "devDependencies": {
"@angular/build": "^22.1.8", "@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8", "@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0", "@angular/compiler-cli": "^22.1.7",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"jsdom": "^28.0.0", "jsdom": "^30.1.0",
"prettier": "^3.8.1", "prettier": "^3.9.8",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vitest": "^4.0.8" "vitest": "^4.1.11"
} }
}, },
"node_modules/@acemir/cssom": {
"version": "0.9.31",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
"dev": true,
"license": "MIT"
},
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -255,9 +248,9 @@
} }
}, },
"node_modules/@angular/common": { "node_modules/@angular/common": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.7.tgz",
"integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==", "integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -266,14 +259,14 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/compiler": { "node_modules/@angular/compiler": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz",
"integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==", "integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -283,9 +276,9 @@
} }
}, },
"node_modules/@angular/compiler-cli": { "node_modules/@angular/compiler-cli": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz",
"integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==", "integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -306,7 +299,7 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/compiler": "22.1.6", "@angular/compiler": "22.1.7",
"typescript": ">=6.0 <6.1" "typescript": ">=6.0 <6.1"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -316,9 +309,9 @@
} }
}, },
"node_modules/@angular/core": { "node_modules/@angular/core": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz",
"integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==", "integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -327,7 +320,7 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/compiler": "22.1.6", "@angular/compiler": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0", "rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0" "zone.js": "~0.15.0 || ~0.16.0"
}, },
@@ -341,9 +334,9 @@
} }
}, },
"node_modules/@angular/forms": { "node_modules/@angular/forms": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.7.tgz",
"integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==", "integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
@@ -354,16 +347,16 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"@angular/platform-browser": "22.1.6", "@angular/platform-browser": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/platform-browser": { "node_modules/@angular/platform-browser": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.7.tgz",
"integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==", "integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -372,9 +365,9 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/animations": "22.1.6", "@angular/animations": "22.1.7",
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6" "@angular/core": "22.1.7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@angular/animations": { "@angular/animations": {
@@ -383,9 +376,9 @@
} }
}, },
"node_modules/@angular/router": { "node_modules/@angular/router": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.7.tgz",
"integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==", "integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -394,60 +387,45 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"@angular/platform-browser": "22.1.6", "@angular/platform-browser": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
"version": "5.1.11", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.3.0",
"@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0" "@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
} }
}, },
"node_modules/@asamuzakjp/dom-selector": { "node_modules/@asamuzakjp/dom-selector": {
"version": "6.8.1", "version": "9.1.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.1.0",
"bidi-js": "^1.0.3", "css-tree": "^3.2.1",
"css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.6" "lru-cache": "^11.5.2"
}
}, },
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
} }
}, },
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
@@ -854,9 +832,9 @@
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.13", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -879,9 +857,9 @@
} }
}, },
"node_modules/@csstools/css-tokenizer": { "node_modules/@csstools/css-tokenizer": {
"version": "4.0.0", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4490,22 +4468,6 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/cssstyle": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
"css-tree": "^3.1.0",
"lru-cache": "^11.2.6"
},
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5283,30 +5245,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/http-proxy-agent/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
@@ -5529,39 +5467,38 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "28.1.0", "version": "30.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^6.8.1", "@asamuzakjp/dom-selector": "^9.1.2",
"@bramus/specificity": "^2.4.2", "@bramus/specificity": "^2.4.2",
"@exodus/bytes": "^1.11.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"cssstyle": "^6.0.1", "@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0", "data-urls": "^7.0.0",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0", "html-encoding-sniffer": "^6.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"parse5": "^8.0.0", "lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0", "saxes": "^6.0.0",
"symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2",
"tough-cookie": "^6.0.0", "undici": "^8.10.2",
"undici": "^7.21.0",
"w3c-xmlserializer": "^5.0.0", "w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1", "webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0", "whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0", "whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0" "xml-name-validator": "^5.0.0"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"canvas": "^3.0.0" "canvas": "^3.2.3"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"canvas": { "canvas": {
@@ -5569,28 +5506,19 @@
} }
} }
}, },
"node_modules/jsdom/node_modules/agent-base": { "node_modules/jsdom/node_modules/whatwg-url": {
"version": "7.1.4", "version": "17.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "@exodus/bytes": "^1.15.1",
"debug": "4" "tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
}, },
"engines": { "engines": {
"node": ">= 14" "node": "^22.14.0 || >=24.0.0"
} }
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
@@ -6708,9 +6636,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.9.6", "version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -7308,13 +7236,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -7469,13 +7390,13 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.29.1", "version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=22.19.0"
} }
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
+11 -11
View File
@@ -4,7 +4,7 @@
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "node node_modules/@angular/cli/bin/ng.js build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"test:ci": "ng test --watch=false" "test:ci": "ng test --watch=false"
@@ -12,12 +12,12 @@
"private": true, "private": true,
"packageManager": "npm@11.19.0", "packageManager": "npm@11.19.0",
"dependencies": { "dependencies": {
"@angular/common": "^22.1.0", "@angular/common": "^22.1.7",
"@angular/compiler": "^22.1.0", "@angular/compiler": "^22.1.7",
"@angular/core": "^22.1.0", "@angular/core": "^22.1.7",
"@angular/forms": "^22.1.0", "@angular/forms": "^22.1.7",
"@angular/platform-browser": "^22.1.0", "@angular/platform-browser": "^22.1.7",
"@angular/router": "^22.1.0", "@angular/router": "^22.1.7",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -25,11 +25,11 @@
"devDependencies": { "devDependencies": {
"@angular/build": "^22.1.8", "@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8", "@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0", "@angular/compiler-cli": "^22.1.7",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"jsdom": "^28.0.0", "jsdom": "^30.1.0",
"prettier": "^3.8.1", "prettier": "^3.9.8",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vitest": "^4.0.8" "vitest": "^4.1.11"
} }
} }
+35 -7
View File
@@ -1,21 +1,36 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import {authGuard} from './core/guards/auth-guard'; import { authGuard } from './core/guards/auth-guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, {
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, path: 'login',
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) }, loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) }, },
{
path: 'change-password',
loadComponent: () =>
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
},
{
path: 'forgot-password',
loadComponent: () =>
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
},
{
path: 'reset-password',
loadComponent: () =>
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
},
{ {
path: 'dashboard', path: 'dashboard',
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
}, },
{ {
path: 'sites', path: 'sites',
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList), loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
}, },
{ {
path: 'sites/:siteId', path: 'sites/:siteId',
@@ -23,4 +38,17 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
}, },
{
path: 'recommendations',
canActivate: [authGuard],
loadComponent: () =>
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
},
{
path: 'monitoring/sensors',
canActivate: [authGuard],
data: { role: 'admin' },
loadComponent: () =>
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
},
]; ];
@@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
describe('mockApiInterceptor', () => { describe('mockApiInterceptor', () => {
let http: HttpClient; let http: HttpClient;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
let useMockFixturesInitial: boolean;
beforeEach(() => { beforeEach(() => {
useMockFixturesInitial = environment.useMockFixtures;
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(withInterceptors([mockApiInterceptor])), provideHttpClient(withInterceptors([mockApiInterceptor])),
@@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => {
}); });
afterEach(() => { afterEach(() => {
environment.useMockFixtures = true; environment.useMockFixtures = useMockFixturesInitial;
httpMock.verify(); httpMock.verify();
}); });
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [ export const ALERTS_FIXTURE: Alert[] = [
{ {
alert_id: 'ALR-SITE002-1718458320', alert_id: 5,
timestamp: '2026-09-15T11:12:00',
site_id: 'SITE002', site_id: 'SITE002',
timestamp: '2026-09-15T11:12:00Z',
type: 'threshold',
severity: 'critical', severity: 'critical',
type: 'outage', message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
value: 812.5, value: 812.5,
threshold: 720.0, threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
}, },
{ {
alert_id: 'ALR-SITE003-1718458321', alert_id: 4,
timestamp: '2026-09-15T11:05:00',
site_id: 'SITE003', site_id: 'SITE003',
timestamp: '2026-09-15T11:05:00Z',
type: 'outage',
severity: 'critical', severity: 'critical',
type: 'sensor', message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
message: 'Perte réseau totale sur Data Center Marseille', value: null,
value: 0, threshold: null,
threshold: 0, metric: null,
prediction_id: null,
}, },
{ {
alert_id: 'ALR-SITE005-1718458322', alert_id: 3,
timestamp: '2026-09-15T10:47:00',
site_id: 'SITE005', site_id: 'SITE005',
timestamp: '2026-09-15T10:47:00Z',
type: 'spike',
severity: 'high', severity: 'high',
type: 'threshold', message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
message: 'Usine Toulouse approche de son seuil de capacité',
value: 410.0, value: 410.0,
threshold: 480.0, threshold: 260.0,
metric: 'consumption_kw',
prediction_id: null,
}, },
{ {
alert_id: 'ALR-SITE006-1718458323', alert_id: 2,
timestamp: '2026-09-15T10:30:00',
site_id: 'SITE006', site_id: 'SITE006',
severity: 'medium', timestamp: '2026-09-15T10:30:00Z',
type: 'sensor', type: 'sensor',
message: 'Capteur de température défaillant sur Bureau Lille', severity: 'medium',
value: 0, message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
threshold: 0, value: null,
threshold: null,
metric: null,
prediction_id: null,
}, },
{ {
alert_id: 'ALR-SITE004-1718458324', alert_id: 1,
timestamp: '2026-09-15T09:58:00',
site_id: 'SITE004', site_id: 'SITE004',
severity: 'low', timestamp: '2026-09-15T09:58:00Z',
type: 'anomaly', type: 'anomaly',
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux', severity: 'low',
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
value: 62.0, value: 62.0,
threshold: 55.0, threshold: 55.0,
metric: 'consumption_kwh',
prediction_id: 42,
}, },
]; ];
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service'; import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
const ALERT_API: Alert = {
alert_id: 1,
site_id: 'site-1',
timestamp: '2026-09-16T00:00:00Z',
type: 'threshold',
severity: 'high',
message: 'Dépassement du seuil configuré',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
};
describe('AlertsService', () => { describe('AlertsService', () => {
let service: AlertsService; let service: AlertsService;
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
afterEach(() => httpMock.verify()); afterEach(() => httpMock.verify());
it("appelle le bon endpoint et retourne un tableau d'alertes", () => { it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
let result: unknown; let result: Alert[] = [];
service.getAlerts().subscribe((r) => (result = r)); service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`); const req = httpMock.expectOne(
expect(req.request.method).toBe('GET'); (r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
);
expect(req.request.params.keys()).toEqual([]);
req.flush([ALERT_API]);
req.flush([ expect(result.length).toBe(1);
{ expect(result[0].alert_id).toBe(1);
alert_id: 'ALR-TEST-1', expect(result[0].prediction_id).toBeNull();
timestamp: '2026-09-15T12:00:00', });
site_id: 'SITE001',
severity: 'high',
type: 'threshold',
message: 'Test',
value: 100,
threshold: 90,
},
]);
expect((result as unknown[]).length).toBe(1); it('transmet les filtres site_id et severity en paramètres de requête', () => {
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.params.get('severity')).toBe('high');
req.flush([]);
});
it('ne pose pas de paramètre pour un filtre omis', () => {
service.getAlerts({ site_id: 'SITE001' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.has('severity')).toBe(false);
req.flush([]);
}); });
}); });
@@ -1,13 +1,25 @@
import { Service, inject } from '@angular/core'; import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model'; import { Alert, AlertSeverity } from '../../shared/models/alert.model';
export interface AlertFilters {
site_id?: string;
severity?: AlertSeverity;
}
@Service() @Service()
export class AlertsService { export class AlertsService {
private http = inject(HttpClient); private http = inject(HttpClient);
getAlerts() { getAlerts(filters: AlertFilters = {}) {
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`); let params = new HttpParams();
if (filters.site_id) {
params = params.set('site_id', filters.site_id);
}
if (filters.severity) {
params = params.set('severity', filters.severity);
}
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
} }
} }
@@ -0,0 +1,74 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { RecommendationsService } from './recommendations.service';
import { environment } from '../../../environments/environment';
import { Recommendation } from '../../shared/models/recommendation.model';
const RECOMMANDATION_API: Recommendation = {
recommendation_id: 1,
alert_id: 1,
action: 'Vérifier la consommation',
explanation: 'Pic détecté',
rule_reference: 'spike-v1',
created_at: '2024-01-01T00:00:00Z',
};
describe('RecommendationsService', () => {
let service: RecommendationsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(RecommendationsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('liste les recommandations depuis le bon endpoint', () => {
let result: Recommendation[] = [];
service.getRecommendations().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`);
expect(req.request.method).toBe('GET');
req.flush([RECOMMANDATION_API]);
expect(result.length).toBe(1);
expect(result[0].alert_id).toBe(1);
});
it('décrit une recommandation par son identifiant', () => {
service.getRecommendation(42).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`);
expect(req.request.method).toBe('GET');
req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 });
});
it('déclenche la génération en POST avec le site en paramètre de requête', () => {
let result: unknown;
service.generate('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.body).toBeNull();
req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
});
it('génère pour tout le parc quand aucun site n’est donné', () => {
service.generate().subscribe();
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
);
expect(req.request.params.has('site_id')).toBe(false);
req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 });
});
});
@@ -0,0 +1,34 @@
import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import {
Recommendation,
RecommendationGenerationReport,
} from '../../shared/models/recommendation.model';
@Service()
export class RecommendationsService {
private http = inject(HttpClient);
getRecommendations() {
return this.http.get<Recommendation[]>(`${environment.apiUrl}/recommendations`);
}
getRecommendation(recommendationId: number) {
return this.http.get<Recommendation>(
`${environment.apiUrl}/recommendations/${recommendationId}`,
);
}
generate(siteId?: string) {
let params = new HttpParams();
if (siteId) {
params = params.set('site_id', siteId);
}
return this.http.post<RecommendationGenerationReport>(
`${environment.apiUrl}/recommendations/generate`,
null,
{ 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`);
}
}
@@ -10,7 +10,11 @@
</div> </div>
</div> </div>
<div class="dashboard__actions"> <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> <a routerLink="/sites" class="ev-link">Voir les sites</a>
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
<ev-button <ev-button
class="logout-button" class="logout-button"
variant="secondary" variant="secondary"
@@ -24,87 +28,120 @@
@if (statsError(); as message) { @if (statsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <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) { @if (predictionsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
@if (stats(); as s) { @if (stats(); as s) {
<section class="overview"> <p class="dashboard__status">
<ev-card class="card card--gauge"> <span class="dashboard__pulse" aria-hidden="true"></span>
<span class="card__label">Consommation vs capacité</span> Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis
</p>
<section class="overview" aria-label="Indicateurs du parc">
<ev-card class="kpi kpi--gauge">
<span class="kpi__label">Consommation vs capacité</span>
<app-consumption-gauge <app-consumption-gauge
[consumption]="s.total_consumption_kw" [consumption]="s.total_consumption_kw"
[capacity]="s.total_capacity_kw" [capacity]="s.total_capacity_kw"
/> />
<span class="card__value" <span class="kpi__value">
>{{ s.total_consumption_kw | number: '1.0-1' }} / {{ s.total_consumption_kw | number: '1.0-1' }}
{{ s.total_capacity_kw | number }} kW</span <small>/ {{ s.total_capacity_kw | number }} kW</small>
</span>
</ev-card>
<ev-card class="kpi">
<span class="kpi__label">Charge moyenne du parc</span>
<span class="kpi__value"
>{{ s.average_load_percent | number: '1.0-0' }} <small>%</small></span
> >
</ev-card> <div
class="progress-bar"
<ev-card class="card"> role="progressbar"
<span class="card__label">Charge moyenne du parc</span> aria-valuemin="0"
<span class="card__value">{{ s.average_load_percent }} %</span> aria-valuemax="100"
<div class="progress-bar"> [attr.aria-valuenow]="s.average_load_percent"
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div> >
<div
class="progress-bar__fill"
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
[style.width.%]="s.average_load_percent"
></div>
</div> </div>
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
</ev-card> </ev-card>
<ev-card class="card"> <ev-card class="kpi">
<span class="card__label">Sites suivis</span> <span class="kpi__label">Sites suivis</span>
<span class="card__value">{{ s.total_sites }}</span> <span class="kpi__value">{{ s.total_sites }}</span>
<a routerLink="/sites" class="ev-link kpi__link">Voir la liste des sites</a>
</ev-card> </ev-card>
</section> </section>
}
<div class="dashboard__grid">
<div class="dashboard__main">
@if (stats(); as s) {
<section class="chart-section"> <section class="chart-section">
<h2>Charge et alerte visuelle par site</h2> <h2>Charge par site</h2>
<ev-card class="chart-card">
<app-site-load-chart [sites]="s.sites" /> <app-site-load-chart [sites]="s.sites" />
</ev-card>
</section> </section>
} }
@if (alerts().length > 0) {
<section class="alerts-section">
<h2>Alertes actives</h2>
<ul class="alerts-list">
@for (alert of alerts(); track alert.alert_id) {
<li class="alert-item">
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
<span class="alert-item__message">{{ alert.message }}</span>
</li>
}
</ul>
</section>
}
@if (predictions().length > 0) {
<section class="predictions-section"> <section class="predictions-section">
<h2>Prévisions de consommation</h2> <h2>Prévisions de consommation</h2>
<ul class="predictions-list"> @if (predictions().length > 0) {
<ev-card class="ev-table-card">
<table class="ev-table">
<thead>
<tr>
<th>Site</th>
<th>Prévision</th>
<th>Échéance</th>
<th></th>
</tr>
</thead>
<tbody>
@for (site of predictions(); track site.site_id) { @for (site of predictions(); track site.site_id) {
<li class="prediction-item"> <tr>
<span class="prediction-item__site">{{ site.site_name }}</span> <td>{{ site.site_name }}</td>
@if (site.prediction; as prediction) { @if (site.prediction; as prediction) {
@if (prediction.status === 'available') { @if (prediction.status === 'available') {
<span class="prediction-item__value"> <td class="ev-table__number">
{{ prediction.predicted_value | number: '1.0-1' }} kWh {{ prediction.predicted_value | number: '1.0-1' }} kWh
<span class="prediction-item__target" </td>
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span <td>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</td>
>
</span>
} @else { } @else {
<td>
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{ <ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' prediction.status === 'insufficient_data'
? 'Historique insuffisant'
: 'Erreur'
}}</ev-badge> }}</ev-badge>
</td>
<td class="ev-table__muted">-</td>
} }
} @else { } @else {
<ev-badge tone="neutral">Pas encore de prévision</ev-badge> <td><ev-badge tone="neutral">Pas encore de prévision</ev-badge></td>
<td class="ev-table__muted">-</td>
} }
</li> <td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr>
}
</tbody>
</table>
</ev-card>
} @else if (!predictionsError()) {
<p class="dashboard__empty">Aucune prévision disponible pour le moment.</p>
} }
</ul>
</section> </section>
} </div>
<aside class="dashboard__side" aria-label="Alertes actives">
<app-alert-feed />
</aside>
</div>
</div> </div>
@@ -8,9 +8,11 @@
.dashboard__header { .dashboard__header {
display: flex; display: flex;
flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: 2rem; gap: var(--space-3);
margin-bottom: var(--space-3);
} }
.dashboard__brand { .dashboard__brand {
@@ -20,8 +22,9 @@
h1 { h1 {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: var(--font-size-xl);
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em;
} }
} }
@@ -31,19 +34,69 @@
.dashboard__subtitle { .dashboard__subtitle {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.dashboard__actions { .dashboard__actions {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 1rem; gap: var(--space-2);
.ev-link {
padding: 0.45rem 0.9rem;
border-radius: var(--radius-pill);
background: var(--color-primary-light);
color: var(--color-primary-hover);
font-size: var(--font-size-sm);
transition: background 0.15s ease;
&:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
text-decoration: none;
}
}
}
.dashboard__status {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0 0 var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.dashboard__pulse {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--color-success);
animation: pulse 2s ease-out infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45);
}
100% {
box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard__pulse {
animation: none;
}
} }
h2 { h2 {
font-size: 1.1rem; margin: 0 0 var(--space-3);
font-size: var(--font-size-lg);
font-weight: 600; font-weight: 600;
margin: 0 0 1rem;
} }
.banner-error { .banner-error {
@@ -54,107 +107,124 @@ h2 {
.overview { .overview {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem; gap: var(--space-3);
margin-bottom: 2.5rem; margin-bottom: var(--space-5);
} }
.card { .kpi {
padding: 1.25rem; position: relative;
gap: 0.35rem; overflow: hidden;
padding: var(--space-4);
gap: var(--space-1);
transition:
box-shadow 0.15s ease,
transform 0.15s ease;
&::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: var(--color-primary);
}
&:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-1px);
}
} }
.card--gauge { .kpi--gauge {
align-items: center; align-items: center;
text-align: center; text-align: center;
} }
.card__label { .kpi__label {
font-size: 0.8rem; font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted); color: var(--color-text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.02em; letter-spacing: 0.06em;
} }
.card__value { .kpi__value {
font-size: 1.6rem; font-size: var(--font-size-2xl);
font-weight: 700; font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
small {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
}
}
.kpi__hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.kpi__link {
margin-top: auto;
font-size: var(--font-size-sm);
} }
.progress-bar { .progress-bar {
height: 6px; height: 8px;
margin: var(--space-1) 0;
background: var(--color-border-light); background: var(--color-border-light);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
overflow: hidden; overflow: hidden;
margin-top: 0.25rem;
} }
.progress-bar__fill { .progress-bar__fill {
height: 100%; height: 100%;
background: var(--color-primary);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--color-success);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.progress-bar__fill--warning {
background: var(--color-warning);
}
.progress-bar__fill--danger {
background: var(--color-danger);
}
.dashboard__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
gap: var(--space-4);
align-items: start;
}
.dashboard__side {
position: sticky;
top: var(--space-3);
}
.chart-section { .chart-section {
margin-bottom: 2.5rem; margin-bottom: var(--space-5);
} }
.alerts-list { .chart-card {
list-style: none; padding: var(--space-3);
}
.dashboard__empty {
margin: 0; margin: 0;
padding: 0; font-size: var(--font-size-sm);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
}
.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); color: var(--color-text-muted);
} }
@media (max-width: 900px) {
.dashboard__grid {
grid-template-columns: 1fr;
}
.dashboard__side {
position: static;
}
}
@@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { of, throwError } from 'rxjs'; import { Observable, of, throwError } from 'rxjs';
import { Router, provideRouter } from '@angular/router';
import { Dashboard } from './dashboard'; import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service'; import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service'; import { AlertsService } from '../../core/services/alerts.service';
import { SitesService } from '../../core/services/sites.service';
import { PredictionsService } from '../../core/services/predictions.service'; import { PredictionsService } from '../../core/services/predictions.service';
import {AuthService} from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import {Router, provideRouter} from '@angular/router';
vi.mock('chart.js', () => { vi.mock('chart.js', () => {
class ChartMock { class ChartMock {
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
return { Chart: ChartMock, registerables: [] }; return { Chart: ChartMock, registerables: [] };
}); });
const STATS = { total_sites: 7, sites: [] };
function predictionsMock(sites: unknown[] = []) { function predictionsMock(sites: unknown[] = []) {
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) }; return {
getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })),
};
}
function setup(
options: {
stats?: Observable<unknown>;
predictions?: { getPredictions: ReturnType<typeof vi.fn> };
auth?: Record<string, unknown>;
} = {},
) {
const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) };
const predictions = options.predictions ?? predictionsMock();
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } },
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } },
{ provide: PredictionsService, useValue: predictions },
...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []),
provideRouter([]),
],
});
return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions };
} }
describe('Dashboard', () => { describe('Dashboard', () => {
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it('charge les stats, les alertes et les prévisions au démarrage', async () => { it('charge les stats et les prévisions au démarrage', async () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const { fixture, statsMock, predictions } = setup({
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
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([]),
],
}); });
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
// laisse le timer(0, ...) se déclencher avant de vérifier
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges(); fixture.detectChanges();
expect(statsMock.getSummary).toHaveBeenCalled(); expect(statsMock.getSummary).toHaveBeenCalled();
expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(predictions.getPredictions).toHaveBeenCalled(); expect(predictions.getPredictions).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.predictions().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1);
expect(fixture.componentInstance.statsError()).toBeNull(); expect(fixture.componentInstance.statsError()).toBeNull();
expect(fixture.componentInstance.alertsError()).toBeNull();
expect(fixture.componentInstance.predictionsError()).toBeNull(); expect(fixture.componentInstance.predictionsError()).toBeNull();
}); });
it('délègue les alertes au widget app-alert-feed', () => {
const { fixture } = setup();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-alert-feed')).not.toBeNull();
});
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const statsMock = { const { fixture, statsMock } = setup({
getSummary: vi stats: throwError(() => new Error('API injoignable')),
.fn()
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.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([]),
],
}); });
statsMock.getSummary
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.mockReturnValue(of(STATS));
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
vi.advanceTimersByTime(1); vi.advanceTimersByTime(1);
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
expect(fixture.componentInstance.statsError()).toBeNull(); expect(fixture.componentInstance.statsError()).toBeNull();
}); });
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(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", () => { 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 { fixture } = setup({
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
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(); fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0); expect(fixture.componentInstance.predictions().length).toBe(0);
@@ -138,25 +112,11 @@ describe('Dashboard', () => {
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const { fixture } = setup({
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
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(); fixture.detectChanges();
expect(fixture.componentInstance.predictionsError()).not.toBeNull(); expect(fixture.componentInstance.predictionsError()).not.toBeNull();
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
@@ -168,105 +128,106 @@ describe('Dashboard', () => {
}); });
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { 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 authMock = {
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; logout: vi.fn().mockReturnValue(of(undefined)),
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() }; clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
TestBed.configureTestingModule({ };
imports: [Dashboard], const { fixture } = setup({ auth: authMock });
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button'); fixture.nativeElement.querySelector('.logout-button').click();
button.click();
expect(authMock.logout).toHaveBeenCalled(); expect(authMock.logout).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);
}); });
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { 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'))), logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(), clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
}; };
TestBed.configureTestingModule({ const { fixture } = setup({ auth: authMock });
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button'); fixture.nativeElement.querySelector('.logout-button').click();
button.click();
expect(authMock.clearSession).toHaveBeenCalled(); expect(authMock.clearSession).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('distingue le ton des sévérités high et critical', () => {
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); it('affiche l’heure du dernier relevé et les indicateurs du parc', () => {
vi.useFakeTimers();
const { fixture } = setup({
stats: of({
timestamp: '2026-09-18T09:00:00Z',
total_sites: 7,
total_consumption_kw: 1234.5,
total_capacity_kw: 5000,
average_load_percent: 24.7,
sites: [],
}),
});
fixture.detectChanges();
vi.advanceTimersByTime(1);
fixture.detectChanges();
const texte = fixture.nativeElement.textContent as string;
expect(texte).toContain('Actualisé à');
expect(texte).toContain('7 sites suivis');
expect(texte).toContain('Marge confortable');
expect(fixture.nativeElement.querySelector('.progress-bar__fill--success')).not.toBeNull();
});
it('présente les prévisions en tableau avec un lien vers chaque site', () => {
const { fixture } = setup({
predictions: predictionsMock([
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
prediction: {
target_at: '2026-09-18T10:00:00Z',
target_metric: 'consumption_kwh',
period_minutes: 60,
predicted_value: 118.4,
status: 'available',
failure_reason: null,
model_reference: 'lightgbm-v1',
created_at: '2026-09-18T09:00:00Z',
},
},
{ site_id: 'SITE002', site_name: 'Bureau Lille', prediction: null },
]),
});
fixture.detectChanges();
const table = fixture.nativeElement.querySelector('table.ev-table');
expect(table).not.toBeNull();
expect(table.textContent).toContain('118.4 kWh');
expect(table.textContent).toContain('Pas encore de prévision');
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE001"]')).not.toBeNull();
});
it('colore la charge moyenne selon les seuils 70 % et 90 %', () => {
const { fixture } = setup();
const dashboard = fixture.componentInstance; const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForSeverity('low')).toBe('success'); expect(dashboard.loadTone(69.9)).toBe('success');
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning'); expect(dashboard.loadTone(70)).toBe('warning');
expect(dashboard.badgeToneForSeverity('high')).toBe('danger'); expect(dashboard.loadTone(89.9)).toBe('warning');
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical'); expect(dashboard.loadTone(90)).toBe('danger');
expect(dashboard.badgeToneForSeverity('high')).not.toBe( expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc');
dashboard.badgeToneForSeverity('critical'),
);
}); });
it('distingue le ton des statuts de prévision', () => { it('distingue le ton des statuts de prévision', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const { fixture } = setup();
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; const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service'; import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertsService } from '../../core/services/alerts.service'; import { AlertFeed } from '../../shared/components/alert-feed/alert-feed';
import { PredictionsService } from '../../core/services/predictions.service'; import { PredictionsService } from '../../core/services/predictions.service';
import { AuthService } from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model'; import { StatsSummary } from '../../shared/models/stats.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
import { Card } from '../../shared/components/ui/card/card'; import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
@@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE = const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.'; 'Données indisponibles, les valeurs affichées datent du dernier relevé.';
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = { // `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` :
low: 'success', // mieux vaut un ton défini que `undefined` le jour où ce statut apparaît.
medium: 'warning',
high: 'danger',
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> = { const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
available: 'success', available: 'success',
insufficient_data: 'warning', insufficient_data: 'warning',
error: 'danger', error: 'danger',
}; };
const SEUIL_CHARGE_SOUTENUE = 70;
const SEUIL_CHARGE_CRITIQUE = 90;
export type LoadTone = 'success' | 'warning' | 'danger';
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
@@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
RouterLink, RouterLink,
ConsumptionGauge, ConsumptionGauge,
SiteLoadChart, SiteLoadChart,
AlertFeed,
Card, Card,
EvAlert, EvAlert,
Badge, Badge,
@@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
}) })
export class Dashboard implements OnInit { export class Dashboard implements OnInit {
private statsService = inject(StatsService); private statsService = inject(StatsService);
private alertsService = inject(AlertsService); public auth = inject(AuthService);
private predictionsService = inject(PredictionsService); private predictionsService = inject(PredictionsService);
private auth = inject(AuthService);
private router = inject(Router); private router = inject(Router);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null); stats = signal<StatsSummary | null>(null);
alerts = signal<Alert[]>([]);
predictions = signal<SitePredictionSummary[]>([]); predictions = signal<SitePredictionSummary[]>([]);
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) // Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, // silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur.
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
statsError = signal<string | null>(null); statsError = signal<string | null>(null);
alertsError = signal<string | null>(null);
predictionsError = signal<string | null>(null); predictionsError = signal<string | null>(null);
ngOnInit(): void { ngOnInit(): void {
this.alertsService
.getAlerts()
.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 // 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`. // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService this.predictionsService
@@ -99,7 +84,9 @@ export class Dashboard implements OnInit {
timer(0, REFRESH_INTERVAL_MS) timer(0, REFRESH_INTERVAL_MS)
.pipe( .pipe(
switchMap(() => switchMap(() =>
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), this.statsService
.getSummary()
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
), ),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
) )
@@ -109,14 +96,28 @@ export class Dashboard implements OnInit {
}); });
} }
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
return TON_PAR_STATUT_PREDICTION[status]; return TON_PAR_STATUT_PREDICTION[status];
} }
loadTone(percent: number): LoadTone {
if (percent >= SEUIL_CHARGE_CRITIQUE) {
return 'danger';
}
return percent >= SEUIL_CHARGE_SOUTENUE ? 'warning' : 'success';
}
loadHint(percent: number): string {
switch (this.loadTone(percent)) {
case 'danger':
return 'Proche de la capacité du parc';
case 'warning':
return 'Charge soutenue';
default:
return 'Marge confortable';
}
}
onLogout(): void { onLogout(): void {
this.auth.logout().subscribe({ this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']), next: () => this.router.navigate(['/login']),
@@ -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;
}
}
@@ -0,0 +1,58 @@
<div class="recommendations">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="recommendations__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="recommendations__logo" />
</a>
<div>
<h1>Recommandations</h1>
<p class="recommendations__subtitle">
Actions proposées par le moteur de règles à partir des alertes
</p>
</div>
</header>
<div class="recommendations__toolbar">
<label class="recommendations__filter">
<span class="form-label">Site</span>
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
<option value="" [selected]="!siteFilter()">Tous les sites</option>
@for (site of sites(); track site.site_id) {
<option [value]="site.site_id" [selected]="site.site_id === siteFilter()">
{{ site.site_name }}
</option>
}
</select>
</label>
@if (isAdmin()) {
<ev-button
variant="secondary"
[fullWidth]="false"
[disabled]="generating()"
data-testid="generate"
(click)="onGenerate()"
>
{{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }}
</ev-button>
}
</div>
@if (generationReport(); as report) {
<ev-alert severity="success" class="recommendations__banner">{{ bilan(report) }}.</ev-alert>
}
@if (generationError(); as message) {
<ev-alert severity="danger" class="recommendations__banner">{{ message }}</ev-alert>
}
@if (alertId(); as id) {
<p class="recommendations__focus">
Alerte n° {{ id }} ·
<a routerLink="/recommendations" class="ev-link">Toutes les recommandations</a>
</p>
}
<app-recommendation-list [siteId]="siteFilter()" [alertId]="alertId()" [sites]="sites()" />
</div>
@@ -0,0 +1,59 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.recommendations__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.recommendations__logo {
font-size: 1.3rem;
}
.recommendations__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.recommendations__toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.recommendations__filter {
display: flex;
flex-direction: column;
min-width: 14rem;
.form-label {
margin-top: 0;
}
}
.recommendations__banner {
display: block;
margin-bottom: var(--space-3);
}
.recommendations__focus {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
color: var(--color-text-muted);
}
@@ -0,0 +1,169 @@
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { RecommendationsView, parseAlertId } from './recommendations';
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
import { SitesService } from '../../core/services/sites.service';
import { AlertsService } from '../../core/services/alerts.service';
import { RecommendationsService } from '../../core/services/recommendations.service';
import { AuthService } from '../../core/services/auth.service';
const SITES = [
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
];
const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 };
function setup(options: { query?: Record<string, string>; role?: string } = {}) {
const query = options.query ?? {};
const queryParamMap = new BehaviorSubject(convertToParamMap(query));
const generate = vi.fn().mockReturnValue(of(BILAN));
const getRecommendations = vi.fn().mockReturnValue(of([]));
const getAlerts = vi.fn().mockReturnValue(of([]));
TestBed.configureTestingModule({
imports: [RecommendationsView],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } },
},
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } },
{ provide: AlertsService, useValue: { getAlerts } },
{ provide: RecommendationsService, useValue: { getRecommendations, generate } },
{
provide: AuthService,
useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) },
},
],
});
const fixture = TestBed.createComponent(RecommendationsView);
fixture.detectChanges();
fixture.detectChanges();
return { fixture, queryParamMap, generate, getRecommendations, getAlerts };
}
function listeEnfant(fixture: ReturnType<typeof setup>['fixture']): RecommendationList {
return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance;
}
describe('parseAlertId', () => {
it("n'accepte qu'un entier strictement positif", () => {
expect(parseAlertId('12')).toBe(12);
expect(parseAlertId('0')).toBeNull();
expect(parseAlertId('-3')).toBeNull();
expect(parseAlertId('abc')).toBeNull();
expect(parseAlertId('12abc')).toBeNull();
expect(parseAlertId(null)).toBeNull();
});
});
describe('RecommendationsView', () => {
it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => {
const { fixture } = setup({ query: { alert: '12' } });
expect(fixture.componentInstance.alertId()).toBe(12);
expect(listeEnfant(fixture).alertId()).toBe(12);
expect(fixture.nativeElement.textContent).toContain('Alerte n° 12');
expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull();
});
it('ignore un paramètre alert invalide', () => {
const { fixture } = setup({ query: { alert: 'abc' } });
expect(fixture.componentInstance.alertId()).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Alerte n°');
});
it('applique le site donné par ?site= au filtre et à la liste', () => {
const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } });
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
const option = fixture.nativeElement.querySelector(
'option[value="SITE001"]',
) as HTMLOptionElement;
expect(option.selected).toBe(true);
});
it('relance la liste sur le site choisi dans le filtre', () => {
const { fixture, getAlerts } = setup();
const select = fixture.nativeElement.querySelector(
'[data-testid="site-filter"]',
) as HTMLSelectElement;
select.value = 'SITE001';
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
fixture.detectChanges();
expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' });
expect(listeEnfant(fixture).siteId()).toBe('SITE001');
});
it('cache le bouton de génération aux lecteurs', () => {
const { fixture } = setup({ role: 'lecteur' });
expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull();
});
it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => {
const { fixture, generate, getRecommendations } = setup({
role: 'admin',
query: { site: 'SITE001' },
});
fixture.nativeElement.querySelector('[data-testid="generate"]').click();
fixture.detectChanges();
fixture.detectChanges();
expect(generate).toHaveBeenCalledWith('SITE001');
expect(fixture.nativeElement.textContent).toContain(
'3 recommandations créées, 1 déjà présente, 2 alertes examinées.',
);
expect(getRecommendations).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.generating()).toBe(false);
});
it('génère pour tout le parc quand aucun site n’est filtré', () => {
const { fixture, generate } = setup({ role: 'admin' });
fixture.componentInstance.onGenerate();
expect(generate).toHaveBeenCalledWith(undefined);
});
it("signale l'échec de la génération sans casser la page", () => {
const { fixture, generate } = setup({ role: 'admin' });
generate.mockReturnValue(throwError(() => new Error('403')));
fixture.componentInstance.onGenerate();
fixture.detectChanges();
expect(fixture.componentInstance.generationError()).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain(
'La génération des recommandations a échoué',
);
expect(fixture.componentInstance.generating()).toBe(false);
});
it('accorde le bilan au singulier', () => {
const { fixture } = setup();
expect(
fixture.componentInstance.bilan({
alerts_examined: 1,
recommendations_created: 1,
already_present: 0,
}),
).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée');
});
});
@@ -0,0 +1,85 @@
import { Component, computed, inject, signal, viewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { SitesService } from '../../core/services/sites.service';
import { RecommendationsService } from '../../core/services/recommendations.service';
import { AuthService } from '../../core/services/auth.service';
import { Site } from '../../shared/models/site.model';
import { RecommendationGenerationReport } from '../../shared/models/recommendation.model';
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
import { Brand } from '../../shared/components/ui/brand/brand';
import { Button } from '../../shared/components/ui/button/button';
const GENERATION_FAILED_MESSAGE =
'La génération des recommandations a échoué, réessayez plus tard.';
export function parseAlertId(raw: string | null): number | null {
return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
}
function pluriel(nombre: number, singulier: string, plurielForme: string): string {
return `${nombre} ${nombre > 1 ? plurielForme : singulier}`;
}
@Component({
selector: 'app-recommendations',
standalone: true,
imports: [RouterLink, RecommendationList, EvAlert, Brand, Button],
templateUrl: './recommendations.html',
styleUrl: './recommendations.scss',
})
export class RecommendationsView {
private route = inject(ActivatedRoute);
private sitesService = inject(SitesService);
private recommendationsService = inject(RecommendationsService);
private auth = inject(AuthService);
alertId = toSignal(
this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))),
{ initialValue: null },
);
siteFilter = signal<string | null>(this.route.snapshot.queryParamMap.get('site'));
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
initialValue: [] as Site[],
});
list = viewChild.required(RecommendationList);
isAdmin = computed(() => this.auth.principal()?.role === 'admin');
generating = signal(false);
generationReport = signal<RecommendationGenerationReport | null>(null);
generationError = signal<string | null>(null);
onSiteChange(event: Event): void {
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
}
onGenerate(): void {
if (this.generating()) {
return;
}
this.generating.set(true);
this.generationError.set(null);
this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({
next: (report) => {
this.generating.set(false);
this.generationReport.set(report);
this.list().reload();
},
error: () => {
this.generating.set(false);
this.generationError.set(GENERATION_FAILED_MESSAGE);
},
});
}
bilan(report: RecommendationGenerationReport): string {
return [
pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'),
pluriel(report.already_present, 'déjà présente', 'déjà présentes'),
pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'),
].join(', ');
}
}
@@ -81,5 +81,16 @@
} }
} }
<section class="recommendations-section">
<h2>Recommandations</h2>
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
<a
routerLink="/recommendations"
[queryParams]="{ site: siteId() }"
class="ev-link recommendations-section__link"
>Voir dans la vue recommandations</a
>
</section>
<a routerLink="/sites" class="ev-link">Retour aux sites</a> <a routerLink="/sites" class="ev-link">Retour aux sites</a>
</div> </div>
@@ -117,3 +117,12 @@ h2 {
.chart-section { .chart-section {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.recommendations-section {
margin: 2.5rem 0 1.5rem;
}
.recommendations-section__link {
display: inline-block;
margin-top: 1rem;
}
@@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs';
import { SiteDetail } from './site-detail'; import { SiteDetail } from './site-detail';
import { SitesService } from '../../../core/services/sites.service'; import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service'; import { ReadingsService } from '../../../core/services/readings.service';
import { AlertsService } from '../../../core/services/alerts.service';
import { RecommendationsService } from '../../../core/services/recommendations.service';
const SITE = { const SITE = {
site_id: 'SITE001', site_id: 'SITE001',
@@ -69,6 +71,7 @@ function setup(
readingsMock: Partial<ReadingsService>, readingsMock: Partial<ReadingsService>,
) { ) {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId })); const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
const getAlerts = vi.fn().mockReturnValue(of([]));
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [SiteDetail], imports: [SiteDetail],
providers: [ providers: [
@@ -76,9 +79,14 @@ function setup(
{ provide: ActivatedRoute, useValue: { paramMap } }, { provide: ActivatedRoute, useValue: { paramMap } },
{ provide: SitesService, useValue: sitesMock }, { provide: SitesService, useValue: sitesMock },
{ provide: ReadingsService, useValue: readingsMock }, { provide: ReadingsService, useValue: readingsMock },
{ provide: AlertsService, useValue: { getAlerts } },
{
provide: RecommendationsService,
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
},
], ],
}); });
return { fixture: TestBed.createComponent(SiteDetail), paramMap }; return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
} }
describe('SiteDetail', () => { describe('SiteDetail', () => {
@@ -261,6 +269,27 @@ describe('SiteDetail', () => {
); );
}); });
it('demande les recommandations du site consulté à travers ses alertes', () => {
const { fixture, getAlerts } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
fixture.detectChanges();
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Recommandations');
expect(
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
).not.toBeNull();
});
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => { it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
const getHistory = vi.fn().mockReturnValue(of([])); const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup( const { fixture } = setup(
@@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand'; import { Brand } from '../../../shared/components/ui/brand/brand';
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge'; import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart'; import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.'; const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.'; const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
@@ -96,7 +97,16 @@ export interface MetricView {
@Component({ @Component({
selector: 'app-site-detail', selector: 'app-site-detail',
standalone: true, standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart], imports: [
RouterLink,
Card,
Alert,
Badge,
Brand,
ConsumptionGauge,
ReadingHistoryChart,
RecommendationList,
],
templateUrl: './site-detail.html', templateUrl: './site-detail.html',
styleUrl: './site-detail.scss', styleUrl: './site-detail.scss',
}) })
@@ -117,6 +127,11 @@ export class SiteDetail {
hasMeasurement = computed(() => this.current()?.timestamp != null); hasMeasurement = computed(() => this.current()?.timestamp != null);
siteAsList = computed<Site[]>(() => {
const site = this.site();
return site ? [site] : [];
});
consumptionKw = computed(() => this.current()?.consumption_kw ?? null); consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
consumptionLabel = computed(() => { consumptionLabel = computed(() => {
@@ -17,8 +17,8 @@
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
<ev-card class="table-card"> <ev-card class="ev-table-card">
<table class="sites-table"> <table class="ev-table">
<thead> <thead>
<tr> <tr>
<th>Nom</th> <th>Nom</th>
@@ -36,7 +36,9 @@
<td>{{ site.site_type }}</td> <td>{{ site.site_type }}</td>
<td>{{ site.location || '-' }}</td> <td>{{ site.location || '-' }}</td>
<td>{{ site.capacity_kw ?? '-' }}</td> <td>{{ site.capacity_kw ?? '-' }}</td>
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td> <td>
<ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge>
</td>
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td> <td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr> </tr>
} }
@@ -32,32 +32,3 @@
display: block; display: block;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
} }
.table-card {
padding: 0;
overflow: hidden;
}
.sites-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
}
@@ -0,0 +1,95 @@
<section class="alert-feed">
<header class="alert-feed__header">
<div>
<h2 class="alert-feed__title">Alertes actives</h2>
@if (!loading() || alerts().length > 0) {
<p class="alert-feed__count">
{{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }}
</p>
}
</div>
<div class="alert-feed__filters">
@if (!siteId()) {
<label class="alert-feed__filter">
<span class="form-label">Site</span>
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
<option value="">Tous les sites</option>
@for (site of sites(); track site.site_id) {
<option [value]="site.site_id">{{ site.site_name }}</option>
}
</select>
</label>
}
<label class="alert-feed__filter">
<span class="form-label">Sévérité</span>
<select
class="form-select"
data-testid="severity-filter"
(change)="onSeverityChange($event)"
>
<option value="">Toutes</option>
@for (severite of severites; track severite) {
<option [value]="severite">{{ severityLabel(severite) }}</option>
}
</select>
</label>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="alert-feed__banner">{{ message }}</ev-alert>
}
@if (loading() && alerts().length === 0 && !error()) {
<p class="alert-feed__state" aria-live="polite">Chargement des alertes…</p>
} @else if (alerts().length === 0 && !error()) {
<ev-alert severity="success" class="alert-feed__banner"
>Aucune alerte pour ces critères.</ev-alert
>
}
<ul class="alert-feed__list" [attr.aria-busy]="loading()">
@for (alert of visibleAlerts(); track alert.alert_id) {
<li class="alert-feed__item" [class]="'alert-feed__item--' + alert.severity">
<span class="alert-feed__icon">
<ev-icon [name]="alert.type" [label]="typeLabel(alert.type)" />
</span>
<div class="alert-feed__body">
<div class="alert-feed__meta">
<ev-badge [tone]="toneFor(alert.severity)">{{
severityLabel(alert.severity)
}}</ev-badge>
<span class="alert-feed__type">{{ typeLabel(alert.type) }}</span>
<span class="alert-feed__site">{{ siteName(alert.site_id) }}</span>
<time class="alert-feed__time" [attr.datetime]="alert.timestamp">
{{ alert.timestamp | date: 'dd/MM/yyyy HH:mm' }}
</time>
</div>
<p class="alert-feed__message">{{ alert.message }}</p>
@if (alert.value !== null) {
<p class="alert-feed__values">
<strong>{{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }}</strong>
@if (alert.threshold !== null) {
<span
>seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }}</span
>
}
</p>
}
</div>
</li>
}
</ul>
@if (hiddenCount() > 0) {
<ev-button
variant="secondary"
[fullWidth]="false"
class="alert-feed__more"
data-testid="show-more"
(click)="showMore()"
>
Afficher plus ({{ hiddenCount() }} restantes)
</ev-button>
}
</section>
@@ -0,0 +1,154 @@
:host {
display: block;
}
.alert-feed__header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.alert-feed__title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: 600;
}
.alert-feed__count {
margin: 0.15rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.alert-feed__filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.alert-feed__filter {
display: flex;
flex-direction: column;
min-width: 10rem;
.form-label {
margin-top: 0;
}
}
.alert-feed__banner {
display: block;
margin-bottom: var(--space-3);
}
.alert-feed__state {
margin: 0 0 var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.alert-feed__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.alert-feed__item {
display: flex;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-left: 4px solid var(--color-border);
}
.alert-feed__item--medium {
border-left-color: var(--color-warning);
background: var(--color-warning-bg);
.alert-feed__icon {
color: var(--color-warning-text);
}
}
.alert-feed__item--high {
border-left-color: var(--color-danger);
background: var(--color-danger-bg);
.alert-feed__icon {
color: var(--color-danger);
}
}
.alert-feed__item--critical {
border-left-color: var(--color-critical);
background: var(--color-danger-bg);
.alert-feed__icon {
color: var(--color-critical);
}
}
.alert-feed__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: var(--font-size-lg);
box-shadow: var(--shadow-card);
}
.alert-feed__body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.alert-feed__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.alert-feed__type {
font-weight: 600;
color: var(--color-text);
}
.alert-feed__message {
margin: 0;
font-size: var(--font-size-sm);
}
.alert-feed__values {
display: flex;
gap: var(--space-2);
margin: 0;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
strong {
color: var(--color-text);
}
}
.alert-feed__more {
display: inline-block;
margin-top: var(--space-3);
}

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