Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012i5NteMLRZgfTAKB5GD37X
2026-09-17 16:43:15 +02:00
Johan LEROYandGitHub 56c134beb0 Merge pull request #95 from ineszang/feat/site-list-view
feat(frontend): vue liste des sites
2026-09-17 16:04:09 +02:00
ineszang44 19cfac1cff fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:48:52 +02:00
ineszang44 1fce577a78 fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:45:54 +02:00
ineszang44 1afaee069f fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:39:48 +02:00
ineszang44 7bc9a09489 fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:38:08 +02:00
ineszang44 4ee2109628 fix(frontend): prise en compte du répertoire du reporter 2026-09-17 14:32:14 +02:00
ineszang44 334ca5982b fix(frontend): chemin vers lcov.info 2026-09-17 14:26:22 +02:00
ineszang44 2465021d61 fix(frontend): chemin vers lcov.info 2026-09-17 14:22:30 +02:00
ineszang44 278299c2b1 test: configuration sonarqube + config angular 2026-09-17 14:18:54 +02:00
Valentin 1cca4f130c chore(ci): ajoute la couverture de tests au job test du frontend
Frontend / build (push) Successful in 10m14s
Frontend / test (push) Failing after 5m1s
Frontend / SonarQube (push) Skipped
2026-09-17 13:55:17 +02:00
ineszang44 ed7311d4ef test: configuration sonarqube 2026-09-17 13:36:41 +02:00
ineszang44 ff68a51424 feat(frontend): fichier de config pour vitest 2026-09-17 13:36:21 +02:00
ineszang44 11f9b1bcd5 test(frontend): sonarqube 2026-09-17 13:04:36 +02:00
ineszang44 41c18a3bb1 test: sonarqube 2026-09-17 12:47:44 +02:00
ineszang44 00ef725249 test: sonarqube 2026-09-17 12:46:04 +02:00
ineszang44 34f35f3ca0 test: sonarqube 2026-09-17 12:26:04 +02:00
Valentin f5cac1c2a8 chore(ci): ajoute un audit de sécurité des dépendances (npm audit, pip-audit) 2026-09-17 12:25:15 +02:00
ineszang44 8e07168a5e test: ajout de propriétés dans le fichier de config pour sonarqube 2026-09-17 12:19:26 +02:00
Johan LEROY d167b64188 Merge remote-tracking branch 'origin/dev' into feat/endpoint-sites-current
# Conflicts:
#	apps/backend/app/repositories/reading.py
#	apps/backend/openapi.json
#	docs/architecture/20-backend.md
2026-09-17 12:17:05 +02:00
Johan LEROY 2f97e4d434 fix(backend): corrige formatage ruff et typage mypy sur sites/current
CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non
annoté, assignation Literal non étroite). Corrige sans changer le
comportement.
2026-09-16 15:27:05 +02:00
Johan LEROY 07ea8d21dc feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29
Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant
la vérification d'existence déjà en place pour GET /sites/{site_id} :
SiteService gagne une dépendance ReadingRepository, sur le modèle de
composition déjà utilisé par StatsService/SensorService. Un site connu
sans lecture rend 200 avec les champs de mesure à null et
data_quality="critical" ; seul un site_id absent rend 404.
2026-09-16 15:25:14 +02:00
82 changed files with 7802 additions and 307 deletions
+84
View File
@@ -56,3 +56,87 @@ jobs:
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici. # Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
- name: Tests et couverture - name: Tests et couverture
run: uv run pytest --cov-fail-under=85 run: uv run pytest --cov-fail-under=85
# Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première
# migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un
# écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre.
integration:
name: Tests exigeant une base
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
services:
db:
image: timescale/timescaledb-ha:pg17
env:
POSTGRES_USER: enervision
POSTGRES_PASSWORD: change_me
POSTGRES_DB: enervision_test
ports:
- "5433:5432"
options: >-
--health-cmd "pg_isready -U enervision -d enervision_test"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 40s
env:
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test
APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
# Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est
# pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1.
- name: Active TimescaleDB sur la base de test
run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
- name: Applique les migrations
run: uv run alembic upgrade head
# `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job
# ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %.
- name: Tests d'intégration
run: uv run pytest -m integration --no-cov
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
# L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait
# aussi les paquets que son propre `--with` injecte, hors dépendances du projet.
- name: Audite les dépendances livrées
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
shell: bash
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
+23 -36
View File
@@ -1,20 +1,6 @@
name: Frontend name: Frontend
# Pipeline à choix multiple
on: on:
# workflow_dispatch -> lancement manuel des jobs
workflow_dispatch:
inputs:
job_choice:
required: true
description: "Choix du job"
type: choice
default: all
options:
- build
- sonarqube
- test
- all # lancer tous les jobs
push: push:
paths: paths:
- "apps/frontend/**" - "apps/frontend/**"
@@ -23,8 +9,9 @@ on:
paths: paths:
- "apps/frontend/**" - "apps/frontend/**"
- ".github/workflows/frontend.yml" - ".github/workflows/frontend.yml"
# Ordre de lancement des jobs
# build -> test -> sonarqube -> deploy permissions:
contents: read
jobs: jobs:
build: build:
@@ -42,6 +29,18 @@ jobs:
- run: npm run build - run: npm run build
working-directory: apps/frontend working-directory: apps/frontend
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend
test: test:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -52,26 +51,14 @@ jobs:
node-version: 24 node-version: 24
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci - name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend working-directory: apps/frontend
- run: npm test -- --watch=false - name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend working-directory: apps/frontend
- name: Upload coverage
sonarqube: uses: actions/upload-artifact@v4
needs: [build, test]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis name: frontend-coverage
- name: SonarQube Scan path: apps/frontend/coverage/frontend/lcov.info
uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# deploy:
# runs-on: ubuntu-latest
# steps:
# - run: echo "DEPLOY job is running"
+132
View File
@@ -0,0 +1,132 @@
name: SonarQube
on:
push:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- ".github/workflows/sonarqube.yml"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- ".github/workflows/sonarqube.yml"
# Build l'ensemble du projet, puis lance les tests
# Génère les rapports de couverture, puis lance l'analyse SonarQube
jobs:
build-front:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test-front:
needs: build-front
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
build-back:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: apps/backend
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: apps/backend
- name: Vérifie le formatage
run: uv run ruff format --check .
working-directory: apps/backend
- name: Analyse statique
run: uv run ruff check --output-format=github .
working-directory: apps/backend
- name: Typage
run: uv run mypy app
working-directory: apps/backend
test-back:
needs: build-back
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name : Lancement des tests et génénration du rapport de couverture (Back)
run: uv run pytest --cov-fail-under=85 --cov-report=xml
working-directory: apps/backend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: apps/backend/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4
with:
name: backend-coverage
path: apps/backend
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+4 -1
View File
@@ -6,7 +6,7 @@ ML := ml
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ .PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \ lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
ml-lint ml-typecheck ml-test ml-check ml-train ml-lint ml-typecheck ml-test ml-check ml-train ml-score
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}'
@@ -74,6 +74,9 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL 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
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
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)
+21 -3
View File
@@ -123,6 +123,11 @@ async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> Non
defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre
declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus. declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus.
Ces tests ne sont pas pour autant facultatifs : le job `integration` de
`.github/workflows/backend.yml` monte un service TimescaleDB, applique les migrations et
les joue a chaque poussee. Un test `integration` casse donc la CI comme un autre. En local,
`make db-up` puis `make test-integration`.
## Couverture ## Couverture
Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique
@@ -142,14 +147,27 @@ uv run pytest tests/api/test_health.py # un seul fichier
uv run pytest -k readiness # par motif de nom uv run pytest -k readiness # par motif de nom
``` ```
## Trois fichiers à connaître avant de toucher à l'authentification ## Quatre fichiers à connaître avant de toucher à l'authentification
`tests/api/acces.py` porte la classification des routes du contrat, en quatre ensembles :
`ROUTES_PUBLIQUES`, `ROUTE_COOKIE`, `ROUTES_SANS_ROLE` et la table `ROLE_MINIMUM`. Ce n'est pas
un fichier de test, c'est la référence que les trois autres confrontent au comportement observé.
**Toute route ajoutée doit y être classée** : `test_every_declared_route_is_classified` échoue
sinon, et échoue aussi sur une entrée qui ne correspond plus à aucune route.
`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et `tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de
dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route
peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose
donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la donc de modifier `ROUTES_PUBLIQUES` dans `acces.py`**, ce qui apparaît en clair dans la diff
diff d'une pull request. d'une pull request.
`tests/api/test_matrice_acces.py` croise chaque route gardée avec chacun des trois rôles, dans
les deux sens : un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le
reçoit jamais. Le second sens est ce qui rend visible une garde posée trop haut, par exemple
`AdminDep` sur une route de lecture. La même matrice est rejouée sous `integration` avec de vrais
jetons, donc en traversant le décodage du JWT et la relecture du compte en base, que
`dependency_overrides` court-circuite.
`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend `tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend
possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test
+12 -1
View File
@@ -27,6 +27,7 @@ from app.repositories.audit_log import AuditLogRepository
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
from app.repositories.prediction import PredictionRepository
from app.repositories.reading import ReadingRepository from app.repositories.reading import ReadingRepository
from app.repositories.recommendation import RecommendationRepository from app.repositories.recommendation import RecommendationRepository
from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.refresh_token import RefreshTokenRepository
@@ -34,6 +35,7 @@ from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository from app.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.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
from app.services.sensor import SensorService from app.services.sensor import SensorService
@@ -171,7 +173,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)]
def get_site_service(session: SessionDep) -> SiteService: def get_site_service(session: SessionDep) -> SiteService:
return SiteService(sites=SiteRepository(session)) return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session))
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
@@ -212,6 +214,15 @@ def get_sensor_service(session: SessionDep) -> SensorService:
SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)] SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)]
def get_prediction_service(session: SessionDep) -> PredictionService:
return PredictionService(
sites=SiteRepository(session), predictions=PredictionRepository(session)
)
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
async def get_current_principal( async def get_current_principal(
credentials: CredentialsDep, credentials: CredentialsDep,
session: SessionDep, session: SessionDep,
+7
View File
@@ -83,6 +83,13 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "sensors", "name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.", "description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
}, },
{
"name": "predictions",
"description": (
"Dernière prévision de consommation par site, calculée hors ligne par le pipeline "
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
),
},
] ]
cookie_de_rafraichissement = APIKeyCookie( cookie_de_rafraichissement = APIKeyCookie(
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from app.api.deps import LecteurDep, PredictionServiceDep
from app.schemas.prediction import PredictionSummaryResponse
router = APIRouter()
@router.get(
"",
response_model=PredictionSummaryResponse,
summary="Dernière prédiction de consommation par site",
)
async def get_predictions(
_: LecteurDep, service: PredictionServiceDep
) -> PredictionSummaryResponse:
resume = await service.summary()
return PredictionSummaryResponse.model_validate(resume)
+17 -1
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, SiteServiceDep from app.api.deps import LecteurDep, SiteServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse from app.schemas.errors import ErrorResponse
from app.schemas.site import SiteResponse from app.schemas.site import SiteCurrentResponse, SiteResponse
from app.services.site import SiteNotFoundError from app.services.site import SiteNotFoundError
router = APIRouter() router = APIRouter()
@@ -34,3 +34,19 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur ) from erreur
return SiteResponse.model_validate(site) return SiteResponse.model_validate(site)
@router.get(
"/{site_id}/current",
response_model=SiteCurrentResponse,
summary="Dernière mesure d'un site",
responses=REPONSES_INTROUVABLE,
)
async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse:
try:
actuel = await service.current(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteCurrentResponse.model_validate(actuel)
+4
View File
@@ -5,6 +5,7 @@ from app.api.v1.endpoints import (
alerts, alerts,
auth, auth,
health, health,
predictions,
readings, readings,
recommendations, recommendations,
sensors, sensors,
@@ -34,3 +35,6 @@ api_router.include_router(
api_router.include_router( api_router.include_router(
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
) )
api_router.include_router(
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
)
@@ -0,0 +1,28 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Prediction
class PredictionRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def latest_by_site(self) -> Sequence[Prediction]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que
# `ReadingRepository.latest_by_site`. Trié sur `target_at` (couvert par
# `ix_prediction_site_target`) plutôt que `created_at` : c'est la prévision la plus
# récente qui compte pour un tableau de bord, pas forcément le dernier run de scoring.
requete = (
select(Prediction)
.distinct(Prediction.site_id)
.order_by(
Prediction.site_id,
Prediction.target_at.desc(),
Prediction.prediction_id.desc(),
)
)
return (await self._session.scalars(requete)).all()
+15 -2
View File
@@ -13,14 +13,27 @@ class ReadingRepository:
async def latest_by_site(self) -> Sequence[Reading]: async def latest_by_site(self) -> Sequence[Reading]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit. # ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage
# les égalités de timestamp, que `uq_reading_source` autorise à `source` différente.
requete = ( requete = (
select(Reading) select(Reading)
.distinct(Reading.site_id) .distinct(Reading.site_id)
.order_by(Reading.site_id, Reading.timestamp.desc()) .order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc())
) )
return (await self._session.execute(requete)).scalars().all() return (await self._session.execute(requete)).scalars().all()
async def latest_for_site(self, site_id: str) -> Reading | None:
# Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la
# `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard.
requete = (
select(Reading)
.where(Reading.site_id == site_id)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(1)
)
lecture: Reading | None = await self._session.scalar(requete)
return lecture
async def list_history( async def list_history(
self, self,
*, *,
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class PredictionTargetMetric(StrEnum):
CONSUMPTION_KWH = "consumption_kwh"
CONSUMPTION_KW = "consumption_kw"
class PredictionStatus(StrEnum):
AVAILABLE = "available"
INSUFFICIENT_DATA = "insufficient_data"
ERROR = "error"
class SitePredictionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
target_at: datetime
target_metric: PredictionTargetMetric
period_minutes: int | None
predicted_value: float | None
status: PredictionStatus
failure_reason: str | None
model_reference: str
created_at: datetime
class SitePredictionSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
prediction: SitePredictionResponse | None
class PredictionSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
sites: list[SitePredictionSummaryResponse]
+20
View File
@@ -1,3 +1,6 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -10,3 +13,20 @@ class SiteResponse(BaseModel):
location: str | None location: str | None
capacity_kw: float | None capacity_kw: float | None
status: str | None status: str | None
class SiteCurrentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: Literal["good", "partial", "degraded", "critical"]
+18
View File
@@ -0,0 +1,18 @@
# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat
# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur
# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES`
# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli.
from typing import Literal, get_args
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality))
_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)}
def qualite_ou_critique(valeur: str | None) -> DataQuality:
if valeur is None:
return "critical"
return _PAR_VALEUR.get(valeur, "critical")
+68
View File
@@ -0,0 +1,68 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from app.models.energy import Prediction, Site
from app.repositories.prediction import PredictionRepository
from app.repositories.site import SiteRepository
@dataclass(frozen=True, slots=True)
class SitePrediction:
target_at: datetime
target_metric: str
period_minutes: int | None
predicted_value: float | None
status: str
failure_reason: str | None
model_reference: str
created_at: datetime
@dataclass(frozen=True, slots=True)
class SitePredictionSummary:
site_id: str
site_name: str
prediction: SitePrediction | None
@dataclass(frozen=True, slots=True)
class PredictionSummary:
timestamp: datetime
sites: list[SitePredictionSummary]
class PredictionService:
def __init__(self, sites: SiteRepository, predictions: PredictionRepository) -> None:
self._sites = sites
self._predictions = predictions
async def summary(self) -> PredictionSummary:
sites = await self._sites.list_all()
dernieres = {p.site_id: p for p in await self._predictions.latest_by_site()}
return PredictionSummary(
timestamp=datetime.now(UTC),
sites=[_resume_site(site, dernieres.get(site.site_id)) for site in sites],
)
def _resume_site(site: Site, derniere: Prediction | None) -> SitePredictionSummary:
# Piège : l'absence de ligne signifie « jamais scoré », pas une valeur pseudo-statut, qui
# n'existe pas dans la contrainte de la table. `prediction` reste `None` plutôt que de
# fabriquer un statut absent du domaine `available`/`insufficient_data`/`error`.
prediction = None
if derniere is not None:
prediction = SitePrediction(
target_at=derniere.target_at,
target_metric=derniere.target_metric,
period_minutes=derniere.period_minutes,
predicted_value=derniere.predicted_value,
status=derniere.status,
failure_reason=derniere.failure_reason,
model_reference=derniere.model_reference,
created_at=derniere.created_at,
)
return SitePredictionSummary(
site_id=site.site_id, site_name=site.site_name, prediction=prediction
)
+2 -3
View File
@@ -5,12 +5,11 @@ from typing import Literal
from app.models.energy import Reading, Site from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository from app.repositories.site import SiteRepository
from app.services.data_quality import qualite_ou_critique
CapteurStatus = Literal["ok", "failing"] CapteurStatus = Literal["ok", "failing"]
OverallStatus = Literal["ok", "degraded", "critical"] OverallStatus = Literal["ok", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
RAISON_VERS_CAPTEUR: dict[str, str] = { RAISON_VERS_CAPTEUR: dict[str, str] = {
"consumption_sensor_failure": "consumption", "consumption_sensor_failure": "consumption",
"electrical_sensor_failure": "electrical", "electrical_sensor_failure": "electrical",
@@ -80,7 +79,7 @@ def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
overall="critical", overall="critical",
) )
qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" qualite = qualite_ou_critique(derniere.data_quality)
overall = _overall_depuis_qualite(qualite) overall = _overall_depuis_qualite(qualite)
if overall == "critical": if overall == "critical":
+57 -1
View File
@@ -1,7 +1,11 @@
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from app.models.energy import Site from app.models.energy import Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository from app.repositories.site import SiteRepository
from app.services.data_quality import DataQuality, qualite_ou_critique
class SiteError(Exception): class SiteError(Exception):
@@ -12,9 +16,26 @@ class SiteNotFoundError(SiteError):
pass pass
@dataclass(frozen=True, slots=True)
class SiteCurrentReading:
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: DataQuality
class SiteService: class SiteService:
def __init__(self, *, sites: SiteRepository) -> None: def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites self._sites = sites
self._readings = readings
async def list_all(self) -> Sequence[Site]: async def list_all(self) -> Sequence[Site]:
return await self._sites.list_all() return await self._sites.list_all()
@@ -24,3 +45,38 @@ class SiteService:
if site is None: if site is None:
raise SiteNotFoundError(site_id) raise SiteNotFoundError(site_id)
return site return site
async def current(self, site_id: str) -> SiteCurrentReading:
site = await self.get_by_id(site_id)
derniere = await self._readings.latest_for_site(site_id)
if derniere is None:
return SiteCurrentReading(
timestamp=None,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=None,
consumption_kwh=None,
voltage_v=None,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
null_reasons=[],
data_quality="critical",
)
return SiteCurrentReading(
timestamp=derniere.timestamp,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=derniere.consumption_kw,
consumption_kwh=derniere.consumption_kwh,
voltage_v=derniere.voltage_v,
current_a=derniere.current_a,
power_factor=derniere.power_factor,
temperature_celsius=derniere.temperature_celsius,
humidity_percent=derniere.humidity_percent,
null_reasons=derniere.null_reasons or [],
data_quality=qualite_ou_critique(derniere.data_quality),
)
+2 -9
View File
@@ -1,14 +1,10 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Literal
from app.models.energy import Reading, Site from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository from app.repositories.site import SiteRepository
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -58,13 +54,10 @@ class StatsService:
@staticmethod @staticmethod
def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption: def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
capacite = site.capacity_kw or 0 capacite = site.capacity_kw or 0
# Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le
# producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas,
# `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas.
qualite: DataQuality = "critical" qualite: DataQuality = "critical"
consommation = None consommation = None
if derniere is not None and derniere.data_quality in QUALITES_CONNUES: if derniere is not None and derniere.data_quality in QUALITES_CONNUES:
qualite = derniere.data_quality # type: ignore[assignment] qualite = qualite_ou_critique(derniere.data_quality)
consommation = derniere.consumption_kw consommation = derniere.consumption_kw
charge = ( charge = (
File diff suppressed because it is too large Load Diff
+418
View File
@@ -1115,6 +1115,93 @@
} }
} }
}, },
"/api/v1/sites/{site_id}/current": {
"get": {
"tags": [
"sites"
],
"summary": "Dernière mesure d'un site",
"operationId": "get_current_api_v1_sites__site_id__current_get",
"security": [
{
"Jeton d'accès": []
}
],
"parameters": [
{
"name": "site_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Site Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SiteCurrentResponse"
}
}
}
},
"500": {
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InternalErrorResponse"
}
}
}
},
"401": {
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"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"
}
}
}
},
"404": {
"description": "Aucun site ne porte cet identifiant.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/alerts": { "/api/v1/alerts": {
"get": { "get": {
"tags": [ "tags": [
@@ -1632,6 +1719,62 @@
} }
] ]
} }
},
"/api/v1/predictions": {
"get": {
"tags": [
"predictions"
],
"summary": "Dernière prédiction de consommation par site",
"operationId": "get_predictions_api_v1_predictions_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PredictionSummaryResponse"
}
}
}
},
"500": {
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InternalErrorResponse"
}
}
}
},
"401": {
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"Jeton d'accès": []
}
]
}
} }
}, },
"components": { "components": {
@@ -1885,6 +2028,45 @@
], ],
"title": "PasswordChangeRequest" "title": "PasswordChangeRequest"
}, },
"PredictionStatus": {
"type": "string",
"enum": [
"available",
"insufficient_data",
"error"
],
"title": "PredictionStatus"
},
"PredictionSummaryResponse": {
"properties": {
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"sites": {
"items": {
"$ref": "#/components/schemas/SitePredictionSummaryResponse"
},
"type": "array",
"title": "Sites"
}
},
"type": "object",
"required": [
"timestamp",
"sites"
],
"title": "PredictionSummaryResponse"
},
"PredictionTargetMetric": {
"type": "string",
"enum": [
"consumption_kwh",
"consumption_kw"
],
"title": "PredictionTargetMetric"
},
"PrincipalResponse": { "PrincipalResponse": {
"properties": { "properties": {
"id": { "id": {
@@ -2297,6 +2479,238 @@
], ],
"title": "SensorStatusResponse" "title": "SensorStatusResponse"
}, },
"SiteCurrentResponse": {
"properties": {
"timestamp": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Timestamp"
},
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_type": {
"type": "string",
"title": "Site Type"
},
"consumption_kw": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kw"
},
"consumption_kwh": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kwh"
},
"voltage_v": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Voltage V"
},
"current_a": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Current A"
},
"power_factor": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Power Factor"
},
"temperature_celsius": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Temperature Celsius"
},
"humidity_percent": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Humidity Percent"
},
"null_reasons": {
"items": {
"type": "string"
},
"type": "array",
"title": "Null Reasons"
},
"data_quality": {
"type": "string",
"enum": [
"good",
"partial",
"degraded",
"critical"
],
"title": "Data Quality"
}
},
"type": "object",
"required": [
"timestamp",
"site_id",
"site_type",
"consumption_kw",
"consumption_kwh",
"voltage_v",
"current_a",
"power_factor",
"temperature_celsius",
"humidity_percent",
"null_reasons",
"data_quality"
],
"title": "SiteCurrentResponse"
},
"SitePredictionResponse": {
"properties": {
"target_at": {
"type": "string",
"format": "date-time",
"title": "Target At"
},
"target_metric": {
"$ref": "#/components/schemas/PredictionTargetMetric"
},
"period_minutes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Period Minutes"
},
"predicted_value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Predicted Value"
},
"status": {
"$ref": "#/components/schemas/PredictionStatus"
},
"failure_reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Failure Reason"
},
"model_reference": {
"type": "string",
"title": "Model Reference"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"target_at",
"target_metric",
"period_minutes",
"predicted_value",
"status",
"failure_reason",
"model_reference",
"created_at"
],
"title": "SitePredictionResponse"
},
"SitePredictionSummaryResponse": {
"properties": {
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_name": {
"type": "string",
"title": "Site Name"
},
"prediction": {
"anyOf": [
{
"$ref": "#/components/schemas/SitePredictionResponse"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"site_id",
"site_name",
"prediction"
],
"title": "SitePredictionSummaryResponse"
},
"SiteResponse": { "SiteResponse": {
"properties": { "properties": {
"site_id": { "site_id": {
@@ -2752,6 +3166,10 @@
{ {
"name": "sensors", "name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`." "description": "État de santé des capteurs par site. Réservé au rôle `admin`."
},
{
"name": "predictions",
"description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
} }
] ]
} }
+87
View File
@@ -0,0 +1,87 @@
# Pourquoi : classification unique des routes du contrat, lue par test_route_protection.py,
# test_openapi.py et test_matrice_acces.py. Trois listes séparées dérivaient auparavant chacune
# de leur côté, et deux entrées de ROUTES_A_ROLE ne correspondaient plus à aucune route sans que
# rien ne le signale.
# Piège : les trois ensembles doivent rester disjoints et couvrir tout le schéma. C'est
# `test_every_declared_route_is_classified` qui le vérifie, pas la relecture.
from typing import Final
from app.core.roles import Role
Route = tuple[str, str]
ROUTES_PUBLIQUES: Final[frozenset[Route]] = frozenset(
{
("GET", "/api/v1/health/live"),
("GET", "/api/v1/health/ready"),
("POST", "/api/v1/auth/login"),
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
("POST", "/api/v1/auth/logout"),
("POST", "/api/v1/auth/forgot-password"),
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
("POST", "/api/v1/auth/reset-password"),
# Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un
# `Principal`. Le jeton est un secret de 256 bits, non brute-forçable.
("GET", "/api/v1/auth/reset-password/validate"),
("GET", "/metrics"),
}
)
# Le cookie opaque porte seul l'autorisation : sans lui la route rend 401, mais aucun `Principal`
# n'est construit et `require_role` n'entre jamais en jeu.
ROUTE_COOKIE: Final[frozenset[Route]] = frozenset({("POST", "/api/v1/auth/refresh")})
# Authentifiées par `CurrentPrincipalDep` nu, donc hors de `require_role` et, avec lui, hors du
# refus `password_change_required`. Volontaire pour `/auth/password`, qui est la sortie de l'état
# provisoire ; subi pour `/auth/logout-all`, cf. test_matrice_acces.py.
ROUTES_SANS_ROLE: Final[frozenset[Route]] = frozenset(
{
("GET", "/api/v1/auth/me"),
("POST", "/api/v1/auth/password"),
("POST", "/api/v1/auth/logout-all"),
}
)
ROLE_MINIMUM: Final[dict[Route, Role]] = {
("GET", "/api/v1/sites"): Role.LECTEUR,
("GET", "/api/v1/sites/{site_id}"): Role.LECTEUR,
("GET", "/api/v1/sites/{site_id}/current"): Role.LECTEUR,
("GET", "/api/v1/alerts"): Role.LECTEUR,
("GET", "/api/v1/recommendations"): Role.LECTEUR,
("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR,
("GET", "/api/v1/stats/summary"): Role.LECTEUR,
("GET", "/api/v1/readings"): Role.LECTEUR,
("GET", "/api/v1/predictions"): Role.LECTEUR,
("GET", "/api/v1/sensors/status"): Role.ADMIN,
("GET", "/api/v1/users"): Role.ADMIN,
("POST", "/api/v1/users"): Role.ADMIN,
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
("POST", "/api/v1/users/{user_id}/password-reset"): Role.ADMIN,
}
# Piège : `{recommendation_id}` est typé `int` et `{user_id}` est un UUID. Une substitution
# uniforme par une chaîne quelconque rendrait 422 avant d'atteindre la garde de rôle, et le test
# passerait en prouvant autre chose que ce qu'il annonce.
SUBSTITUTIONS: Final[dict[str, str]] = {
"{user_id}": "00000000-0000-0000-0000-000000000000",
"{site_id}": "site-absent-du-jeu-de-donnees",
"{recommendation_id}": "999999999",
}
def chemin_concret(chemin: str) -> str:
for gabarit, valeur in SUBSTITUTIONS.items():
chemin = chemin.replace(gabarit, valeur)
return chemin
def routes_du_schema(schema: dict[str, object]) -> list[Route]:
chemins: dict[str, dict[str, object]] = schema["paths"] # type: ignore[assignment]
return [
(methode.upper(), chemin)
for chemin, operations in chemins.items()
for methode in operations
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
]
@@ -0,0 +1,279 @@
# Pourquoi : la matrice rôle x route sur les routes réelles. `test_authorization.py` la joue déjà,
# mais contre une route jetable montée par une fixture, ce qui ne dit rien du niveau effectivement
# posé sur `/sites` ou `/users`. `ROLE_MINIMUM` (tests/api/acces.py) est la référence, et ce
# fichier est ce qui la confronte au comportement observé.
# Piège : l'assertion porte sur le refus de la garde, pas sur un 200. Un rôle suffisant peut
# légitimement recevoir 404 ou 422 selon les données ; ce qui compte est qu'il ne reçoive pas le
# 403 `Droits insuffisants`. Sans cette nuance, le test dépendrait du contenu de la base.
# Les tests `integration` en fin de fichier rejouent la même matrice avec de vrais jetons, donc en
# traversant le décodage du JWT et la relecture du compte, ce que l'override court-circuite.
import uuid
from collections.abc import AsyncIterator, Callable, Iterator
import pytest
from fastapi import FastAPI
from httpx import AsyncClient, Response
from sqlalchemy import text
from app.api.deps import get_current_principal
from app.core.hashing import build_hasher
from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least
from app.db.session import get_session, get_session_factory
from app.repositories.user import UserRepository
from tests.api.acces import ROLE_MINIMUM, chemin_concret
ROLES = [Role.LECTEUR, Role.OPERATEUR, Role.ADMIN]
IDS_DE_ROLE = ["lecteur", "operateur", "admin"]
REFUS_DE_DROITS = "Droits insuffisants"
REFUS_DE_MOT_DE_PASSE = "password_change_required"
MOT_DE_PASSE = "un-mot-de-passe-de-recette"
# `FakeSession` de tests/factories.py rend un unique objet pour les trois formes d'appel, ce qui
# suffit à un test d'endpoint ciblé mais pas à balayer 13 routes qui interrogent chacune la base
# à sa façon. Ce double rend un résultat vide quelle que soit la forme demandée, pour que la
# réponse observée vienne de la garde de rôle et jamais d'un double mal ajusté.
class ResultatVide:
def scalars(self) -> ResultatVide:
return self
def all(self) -> list[object]:
return []
def first(self) -> None:
return None
def one_or_none(self) -> None:
return None
def scalar_one_or_none(self) -> None:
return None
def mappings(self) -> ResultatVide:
return self
def __iter__(self) -> Iterator[object]:
return iter(())
class SessionMuette:
async def scalar(self, *_: object, **__: object) -> None:
return None
async def execute(self, *_: object, **__: object) -> ResultatVide:
return ResultatVide()
async def scalars(self, *_: object, **__: object) -> ResultatVide:
return ResultatVide()
async def get(self, *_: object, **__: object) -> None:
return None
async def flush(self) -> None:
return None
async def commit(self) -> None:
return None
async def rollback(self) -> None:
return None
def add(self, *_: object, **__: object) -> None:
return None
@pytest.fixture
def base_muette(app: FastAPI) -> None:
async def override() -> AsyncIterator[SessionMuette]:
yield SessionMuette()
app.dependency_overrides[get_session] = override
def principal(role: Role, *, must_change_password: bool = False) -> Principal:
return Principal(
id=uuid.uuid4(),
email=f"matrice-{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=must_change_password,
)
@pytest.fixture
def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]:
def installe(acteur: Principal) -> None:
app.dependency_overrides[get_current_principal] = lambda: acteur
yield installe
app.dependency_overrides.pop(get_current_principal, None)
async def appelle(client: AsyncClient, methode: str, chemin: str, **kwargs: object) -> Response:
return await client.request(methode, chemin_concret(chemin), json={}, **kwargs) # type: ignore[arg-type]
def motif_du_refus(response: Response) -> str | None:
if response.status_code != 403:
return None
detail = response.json().get("detail")
return detail if isinstance(detail, str) else None
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_role_below_the_minimum_is_refused_on_every_guarded_route(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
role: Role,
) -> None:
connecte(principal(role))
laissees_passer: list[tuple[str, str, int]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
if has_at_least(role, minimum):
continue
response = await appelle(client, methode, chemin)
if motif_du_refus(response) != REFUS_DE_DROITS:
laissees_passer.append((methode, chemin, response.status_code))
assert laissees_passer == []
# Le pendant du test précédent : sans lui, une garde posée trop haut, par exemple `AdminDep` sur
# `/sites`, ne ferait échouer aucun test du dépôt.
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_role_at_or_above_the_minimum_is_never_refused_by_the_guard(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
role: Role,
) -> None:
connecte(principal(role))
refusees: list[tuple[str, str]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
if not has_at_least(role, minimum):
continue
response = await appelle(client, methode, chemin)
if motif_du_refus(response) == REFUS_DE_DROITS:
refusees.append((methode, chemin))
assert refusees == []
async def test_a_pending_password_change_is_refused_on_every_guarded_route(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
) -> None:
connecte(principal(Role.ADMIN, must_change_password=True))
laissees_passer: list[tuple[str, str, int]] = []
for methode, chemin in ROLE_MINIMUM:
response = await appelle(client, methode, chemin)
if motif_du_refus(response) != REFUS_DE_MOT_DE_PASSE:
laissees_passer.append((methode, chemin, response.status_code))
assert laissees_passer == []
@pytest.fixture
async def comptes_par_role() -> AsyncIterator[dict[Role, str]]:
marque = uuid.uuid4().hex[:12]
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
empreinte = await hacheur.hash(MOT_DE_PASSE)
adresses = {role: f"matrice-{marque}-{role.value}@enervision.fr" for role in ROLES}
async with get_session_factory()() as session:
depot = UserRepository(session)
for role, email in adresses.items():
await depot.create(email=email, password_hash=empreinte, role=role)
await session.commit()
yield adresses
async with get_session_factory()() as session:
await session.execute(
text("delete from app_user where email like :motif"), {"motif": f"matrice-{marque}-%"}
)
await session.commit()
async def authentifie(client: AsyncClient, email: str) -> dict[str, str]:
reponse = await client.post(
"/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE}
)
assert reponse.status_code == 200, reponse.text
return {"Authorization": f"Bearer {reponse.json()['access_token']}"}
@pytest.mark.integration
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
comptes_par_role: dict[Role, str], client: AsyncClient, role: Role
) -> None:
entetes = await authentifie(client, comptes_par_role[role])
ecarts: list[tuple[str, str, int, str]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
response = await appelle(client, methode, chemin, headers=entetes)
refuse = motif_du_refus(response) == REFUS_DE_DROITS
if refuse is has_at_least(role, minimum):
ecarts.append((methode, chemin, response.status_code, response.text[:120]))
assert ecarts == []
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
@pytest.mark.integration
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank(
comptes_par_role: dict[Role, str], client: AsyncClient
) -> None:
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
divergences: list[tuple[str, str]] = []
for methode, chemin in ROLE_MINIMUM:
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
if cote_lecteur.status_code != cote_operateur.status_code:
divergences.append((methode, chemin))
assert divergences == []
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
# `must_change_password` que seul `require_role` applique. Comportement figé ici, pas corrigé.
@pytest.mark.integration
async def test_a_temporary_password_blocks_the_business_routes_but_not_logout_all(
client: AsyncClient,
) -> None:
marque = uuid.uuid4().hex[:12]
email = f"matrice-{marque}-provisoire@enervision.fr"
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
empreinte = await hacheur.hash(MOT_DE_PASSE)
async with get_session_factory()() as session:
await UserRepository(session).create(
email=email, password_hash=empreinte, role=Role.ADMIN, must_change_password=True
)
await session.commit()
try:
entetes = await authentifie(client, email)
sites = await client.get("/api/v1/sites", headers=entetes)
identite = await client.get("/api/v1/auth/me", headers=entetes)
fermeture = await client.post("/api/v1/auth/logout-all", headers=entetes)
assert motif_du_refus(sites) == REFUS_DE_MOT_DE_PASSE
assert identite.status_code == 200
assert fermeture.status_code == 204
finally:
async with get_session_factory()() as session:
await session.execute(text("delete from app_user where email = :e"), {"e": email})
await session.commit()
+6 -14
View File
@@ -8,6 +8,7 @@ from typing import Any
import pytest import pytest
from app import cli from app import cli
from tests.api.acces import ROLE_MINIMUM
METHODES = {"get", "post", "patch", "put", "delete"} METHODES = {"get", "post", "patch", "put", "delete"}
@@ -24,20 +25,11 @@ ORIGINE_VERIFIEE = {
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour # Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
# `password_change_required`, pas seulement les routes `admin`. # `password_change_required`, pas seulement les routes `admin`.
ROUTES_A_ROLE = { # Piège : cette liste était recopiée ici, et deux de ses entrées portaient `{id}` là où le contrat
("GET", "/api/v1/users"), # expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et le test ci-dessous
("POST", "/api/v1/users"), # passait au vert sans rien vérifier sur ces deux routes. Elle est maintenant dérivée, et
("PATCH", "/api/v1/users/{id}"), # `test_every_declared_route_is_classified` interdit l'entrée morte.
("POST", "/api/v1/users/{id}/password-reset"), ROUTES_A_ROLE = frozenset(ROLE_MINIMUM)
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
("GET", "/api/v1/alerts"),
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
("GET", "/api/v1/readings"),
("GET", "/api/v1/sensors/status"),
}
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@@ -0,0 +1,85 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_prediction_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.prediction import PredictionSummary, SitePrediction, SitePredictionSummary
TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def lecteur() -> Principal:
# Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE`
# de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec
# autre chose que sa valeur par défaut.
return Principal(
id=uuid4(),
email="lecteur@enervision.fr",
role=Role.LECTEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self) -> None:
self.resume = PredictionSummary(
timestamp=datetime.now(UTC),
sites=[
SitePredictionSummary(
site_id="SITE001",
site_name="Bureau Paris La Défense",
prediction=SitePrediction(
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=812.5,
status="available",
failure_reason=None,
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
),
),
SitePredictionSummary(site_id="SITE002", site_name="Usine Lyon", prediction=None),
],
)
async def summary(self) -> PredictionSummary:
return self.resume
@pytest.fixture
def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_prediction_service] = lambda: service
app.dependency_overrides[get_current_principal] = lambda: lecteur()
return service
yield installe
app.dependency_overrides.pop(get_prediction_service, None)
app.dependency_overrides.pop(get_current_principal, None)
async def test_get_predictions_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/predictions")
assert response.status_code == 200
corps = response.json()
premier, second = corps["sites"]
assert premier["site_id"] == "SITE001"
assert premier["prediction"]["predicted_value"] == 812.5
assert premier["prediction"]["status"] == "available"
assert second["site_id"] == "SITE002"
assert second["prediction"] is None
+37 -30
View File
@@ -1,6 +1,6 @@
# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier # Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier
# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et # `ROUTES_PUBLIQUES` dans `tests/api/acces.py`, ce qui apparaît en clair dans la diff d'une pull
# demande une justification au relecteur. # request et demande une justification au relecteur.
# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de # Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de
# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route # dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route
# peut porter la bonne dépendance tout en répondant quand même. # peut porter la bonne dépendance tout en répondant quand même.
@@ -11,58 +11,64 @@ import pytest
from fastapi import FastAPI from fastapi import FastAPI
from httpx import AsyncClient from httpx import AsyncClient
ROUTES_PUBLIQUES = frozenset( from tests.api.acces import (
{ ROLE_MINIMUM,
("GET", "/api/v1/health/live"), ROUTE_COOKIE,
("GET", "/api/v1/health/ready"), ROUTES_PUBLIQUES,
("POST", "/api/v1/auth/login"), ROUTES_SANS_ROLE,
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. Route,
("POST", "/api/v1/auth/logout"), chemin_concret,
("POST", "/api/v1/auth/forgot-password"), routes_du_schema,
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
("POST", "/api/v1/auth/reset-password"),
# Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un
# `Principal`. Le jeton est un secret de 256 bits, non brute-forçable.
("GET", "/api/v1/auth/reset-password/validate"),
("GET", "/metrics"),
}
) )
VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000"
STATUTS_DE_REFUS = {401, 403} STATUTS_DE_REFUS = {401, 403}
HORS_SCHEMA = {("GET", "/metrics")}
def routes_declarees(app: FastAPI) -> list[tuple[str, str]]: def routes_declarees(app: FastAPI) -> list[Route]:
schema: dict[str, Any] = app.openapi() schema: dict[str, Any] = app.openapi()
return [ return routes_du_schema(schema)
(methode.upper(), chemin)
for chemin, operations in schema["paths"].items()
for methode in operations
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
]
def routes_protegees(app: FastAPI) -> list[tuple[str, str]]: def routes_protegees(app: FastAPI) -> list[Route]:
return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES] return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES]
def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None: def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None:
declarees = set(routes_declarees(app)) | {("GET", "/metrics")} declarees = set(routes_declarees(app)) | HORS_SCHEMA
inconnues = ROUTES_PUBLIQUES - declarees inconnues = ROUTES_PUBLIQUES - declarees
assert inconnues == set() assert inconnues == set()
# Sans lui, une route ajoutée sans être classée n'est vue par aucun test de rôle : elle hérite
# du seul contrôle anonyme, et une garde posée au mauvais niveau passe inaperçue.
def test_every_declared_route_is_classified(app: FastAPI) -> None:
classees = ROUTES_PUBLIQUES | ROUTE_COOKIE | ROUTES_SANS_ROLE | set(ROLE_MINIMUM)
non_classees = set(routes_declarees(app)) - classees
fantomes = classees - set(routes_declarees(app)) - HORS_SCHEMA
assert non_classees == set(), "classer la route dans tests/api/acces.py"
assert fantomes == set(), "entrée morte : la route n'existe plus sous ce chemin"
def test_the_four_classes_of_routes_stay_disjoint() -> None:
classes = [ROUTES_PUBLIQUES, ROUTE_COOKIE, ROUTES_SANS_ROLE, frozenset(ROLE_MINIMUM)]
for rang, classe in enumerate(classes):
for autre in classes[rang + 1 :]:
assert classe & autre == frozenset()
async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public( async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public(
app: FastAPI, client: AsyncClient app: FastAPI, client: AsyncClient
) -> None: ) -> None:
ouvertes: list[tuple[str, str, int]] = [] ouvertes: list[tuple[str, str, int]] = []
for methode, chemin in routes_protegees(app): for methode, chemin in routes_protegees(app):
concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION) response = await client.request(methode, chemin_concret(chemin), json={})
response = await client.request(methode, concret, json={})
if response.status_code not in STATUTS_DE_REFUS: if response.status_code not in STATUTS_DE_REFUS:
ouvertes.append((methode, chemin, response.status_code)) ouvertes.append((methode, chemin, response.status_code))
@@ -95,4 +101,5 @@ async def test_the_documentation_routes_are_public_by_design(
app: FastAPI, client: AsyncClient, chemin: str app: FastAPI, client: AsyncClient, chemin: str
) -> None: ) -> None:
response = await client.get(chemin) response = await client.get(chemin)
assert response.status_code == 200 assert response.status_code == 200
+51 -1
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_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 Site from app.models.energy import Site
from app.services.site import SiteNotFoundError from app.services.site import SiteCurrentReading, SiteNotFoundError
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def principal(role: Role = Role.LECTEUR) -> Principal: def principal(role: Role = Role.LECTEUR) -> Principal:
@@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site:
) )
def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading:
return SiteCurrentReading(
timestamp=TIMESTAMP,
site_id=site_id,
site_type="industriel",
consumption_kw=87.34,
consumption_kwh=87.34,
voltage_v=401.2,
current_a=132.5,
power_factor=0.923,
temperature_celsius=22.1,
humidity_percent=58.4,
null_reasons=[],
data_quality="good",
)
class FauxService: 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.site = site() self.site = site()
self.actuel = lecture_actuelle()
async def list_all(self) -> list[Site]: async def list_all(self) -> list[Site]:
return [self.site] return [self.site]
@@ -46,6 +67,11 @@ class FauxService:
raise self._erreur raise self._erreur
return self.site return self.site
async def current(self, site_id: str) -> SiteCurrentReading:
if self._erreur is not None:
raise self._erreur
return self.actuel
@pytest.fixture @pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]: def lecteur_connecte(app: FastAPI) -> Iterator[None]:
@@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site(
assert response.status_code == 404 assert response.status_code == 404
async def test_get_current_returns_the_latest_reading(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sites/site-1/current")
assert response.status_code == 200
corps = response.json()
assert corps["site_id"] == "site-1"
assert corps["data_quality"] == "good"
assert corps["consumption_kw"] == 87.34
async def test_get_current_returns_404_for_an_unknown_site(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(SiteNotFoundError("site-inconnu"))
response = await client.get("/api/v1/sites/site-inconnu/current")
assert response.status_code == 404
async def test_list_sites_reaches_the_repository_through_the_session( async def test_list_sites_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None: ) -> None:
@@ -0,0 +1,92 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Prediction
from app.repositories.prediction import PredictionRepository
from tests.repositories.test_site import creer as creer_site
from tests.repositories.test_site import identifiant as identifiant_site
pytestmark = pytest.mark.integration
async def creer_prediction(
session: AsyncSession, *, site_id: str, **overrides: object
) -> Prediction:
prediction = Prediction(
site_id=site_id,
target_at=overrides.get("target_at", datetime(2026, 9, 16, tzinfo=UTC)),
target_metric=overrides.get("target_metric", "consumption_kwh"),
period_minutes=overrides.get("period_minutes", 60),
predicted_value=overrides.get("predicted_value", 42.0),
model_reference=overrides.get("model_reference", "lightgbm-test"),
status=overrides.get("status", "available"),
failure_reason=overrides.get("failure_reason"),
)
session.add(prediction)
await session.flush()
return prediction
async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
ancienne = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC)
)
recente = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 15, tzinfo=UTC)
)
resultats = await depot.latest_by_site()
identifiants = [
p.prediction_id
for p in resultats
if p.prediction_id in (ancienne.prediction_id, recente.prediction_id)
]
await session.rollback()
assert identifiants == [recente.prediction_id]
async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = PredictionRepository(session)
voulue_premier = await creer_prediction(session, site_id=premier.site_id)
voulue_second = await creer_prediction(session, site_id=second.site_id)
resultats = await depot.latest_by_site()
identifiants = {p.site_id for p in resultats if p.site_id in (premier.site_id, second.site_id)}
await session.rollback()
assert identifiants == {voulue_premier.site_id, voulue_second.site_id}
async def test_latest_by_site_keeps_an_insufficient_data_prediction(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
voulue = await creer_prediction(
session,
site_id=site.site_id,
status="insufficient_data",
predicted_value=None,
failure_reason="pas assez d'historique",
)
resultats = await depot.latest_by_site()
identifiants = [p.prediction_id for p in resultats if p.site_id == site.site_id]
await session.rollback()
assert identifiants == [voulue.prediction_id]
async def test_latest_by_site_returns_an_empty_list_when_there_is_nothing(
session: AsyncSession,
) -> None:
depot = PredictionRepository(session)
resultats = [p for p in await depot.latest_by_site() if p.site_id == identifiant_site()]
assert resultats == []
@@ -88,6 +88,73 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
assert identifiants == {premier, second} assert identifiants == {premier, second}
async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
)
derniere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
)
resultats = await depot.latest_by_site()
retenues = [r.reading_id for r in resultats if r.site_id == site.site_id]
await session.rollback()
assert retenues == [derniere.reading_id]
async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
recente = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
trouvee = await depot.latest_for_site(site.site_id)
reading_id = trouvee.reading_id if trouvee else None
await session.rollback()
assert reading_id == recente.reading_id
async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history")
derniere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current"
)
trouvee = await depot.latest_for_site(site.site_id)
reading_id = trouvee.reading_id if trouvee else None
await session.rollback()
assert reading_id == derniere.reading_id
async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
session: AsyncSession,
) -> None:
sans_lecture = await creer_site(session)
autre = await creer_site(session)
depot = ReadingRepository(session)
await creer_lecture(session, site_id=autre.site_id)
trouvee = await depot.latest_for_site(sans_lecture.site_id)
await session.rollback()
assert trouvee is None
async def test_list_history_orders_the_readings_by_timestamp_descending( async def test_list_history_orders_the_readings_by_timestamp_descending(
session: AsyncSession, session: AsyncSession,
) -> None: ) -> None:
@@ -0,0 +1,121 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from app.services.prediction import PredictionService
TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass
class FauxSite:
site_id: str
site_name: str
@dataclass
class FauxPrediction:
site_id: str
target_at: datetime
target_metric: str
period_minutes: int | None
predicted_value: float | None
status: str
failure_reason: str | None
model_reference: str
created_at: datetime
class FauxDepotSites:
def __init__(self, sites: list[FauxSite]) -> None:
self._sites = sites
async def list_all(self) -> list[FauxSite]:
return self._sites
class FauxDepotPredictions:
def __init__(self, predictions: list[FauxPrediction]) -> None:
self._predictions = predictions
async def latest_by_site(self) -> list[FauxPrediction]:
return self._predictions
def prediction_disponible(site_id: str = "A") -> FauxPrediction:
return FauxPrediction(
site_id=site_id,
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=812.5,
status="available",
failure_reason=None,
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
)
async def test_summary_attaches_the_latest_prediction_to_its_site() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.site_id == "A"
assert site.prediction is not None
assert site.prediction.predicted_value == 812.5
assert site.prediction.status == "available"
async def test_summary_leaves_prediction_none_for_a_site_never_scored() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([]), # type: ignore[arg-type]
)
resume = await service.summary()
assert resume.sites[0].prediction is None
async def test_summary_carries_an_insufficient_data_prediction_without_a_value() -> None:
insuffisante = FauxPrediction(
site_id="A",
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=None,
status="insufficient_data",
failure_reason="pas assez d'historique",
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
)
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([insuffisante]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.prediction is not None
assert site.prediction.status == "insufficient_data"
assert site.prediction.predicted_value is None
assert site.prediction.failure_reason == "pas assez d'historique"
async def test_summary_covers_every_site_even_with_a_single_prediction_in_the_repository() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A"), FauxSite("B", "Site B")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type]
)
resume = await service.summary()
par_site = {site.site_id: site for site in resume.sites}
assert par_site["A"].prediction is not None
assert par_site["B"].prediction is None
+80 -7
View File
@@ -1,8 +1,13 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
import pytest import pytest
from app.models.energy import Site from app.models.energy import Site
from app.services.site import SiteNotFoundError, SiteService from app.services.site import SiteNotFoundError, SiteService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def site(site_id: str = "site-1") -> Site: def site(site_id: str = "site-1") -> Site:
return Site( return Site(
@@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site:
) )
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime = TIMESTAMP
consumption_kw: float | None = 87.34
consumption_kwh: float | None = 87.34
voltage_v: float | None = 401.2
current_a: float | None = 132.5
power_factor: float | None = 0.923
temperature_celsius: float | None = 22.1
humidity_percent: float | None = 58.4
null_reasons: list[str] | None = field(default_factory=list)
data_quality: str | None = "good"
class FakeRepository: class FakeRepository:
def __init__(self, sites: list[Site]) -> None: def __init__(self, sites: list[Site]) -> None:
self._sites = sites self._sites = sites
@@ -26,24 +46,77 @@ class FakeRepository:
return next((s for s in self._sites if s.site_id == site_id), None) return next((s for s in self._sites if s.site_id == site_id), None)
async def test_list_all_returns_the_repository_sites() -> None: class FauxDepotLectures:
service = SiteService(sites=FakeRepository([site("a"), site("b")])) def __init__(self, lectures: dict[str, FauxLecture]) -> None:
self._lectures = lectures
sites = await service.list_all() async def latest_for_site(self, site_id: str) -> FauxLecture | None:
return self._lectures.get(site_id)
def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService:
return SiteService(
sites=FakeRepository(sites), # type: ignore[arg-type]
readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type]
)
async def test_list_all_returns_the_repository_sites() -> None:
svc = service([site("a"), site("b")])
sites = await svc.list_all()
assert [s.site_id for s in sites] == ["a", "b"] assert [s.site_id for s in sites] == ["a", "b"]
async def test_get_by_id_returns_the_matching_site() -> None: async def test_get_by_id_returns_the_matching_site() -> None:
service = SiteService(sites=FakeRepository([site("a")])) svc = service([site("a")])
trouve = await service.get_by_id("a") trouve = await svc.get_by_id("a")
assert trouve.site_id == "a" assert trouve.site_id == "a"
async def test_get_by_id_raises_when_the_site_is_unknown() -> None: async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
service = SiteService(sites=FakeRepository([])) svc = service([])
with pytest.raises(SiteNotFoundError): with pytest.raises(SiteNotFoundError):
await service.get_by_id("inconnu") await svc.get_by_id("inconnu")
async def test_current_raises_when_the_site_is_unknown() -> None:
svc = service([])
with pytest.raises(SiteNotFoundError):
await svc.current("inconnu")
async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None:
svc = service([site("a")])
actuel = await svc.current("a")
assert actuel.timestamp is None
assert actuel.consumption_kw is None
assert actuel.data_quality == "critical"
assert actuel.null_reasons == []
async def test_current_copies_every_field_from_the_latest_reading() -> None:
svc = service([site("a")], {"a": FauxLecture(site_id="a")})
actuel = await svc.current("a")
assert actuel.timestamp == TIMESTAMP
assert actuel.site_type == "industriel"
assert actuel.consumption_kw == 87.34
assert actuel.voltage_v == 401.2
assert actuel.data_quality == "good"
async def test_current_treats_an_unknown_data_quality_as_critical() -> None:
svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)})
actuel = await svc.current("a")
assert actuel.data_quality == "critical"
-9
View File
@@ -86,15 +86,6 @@
"text-summary", "text-summary",
"lcov", "lcov",
"html" "html"
],
"reporters": [
"default",
[
"junit",
{
"outputFile": "test-results/junit.xml"
}
]
] ]
} }
} }
+18
View File
@@ -0,0 +1,18 @@
sonar.projectKey=ProjetPiscine_EnerVision
sonar.organization=groupe3-ener-vision
sonar.sourceEncoding=UTF-8
# Dossier contenant le code source
sonar.sources=apps/frontend/src,apps/backend/app
sonar.tests=apps/backend/tests
# Liste des fichiers et dossiers à exclure de l'analyse
# Liste des fichiers et dossiers à exclure de l'analyse
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
# Chemin vers le rapport de couverture de code
# Fichier généré par Vitest
# Chemin vers le rapport de couverture de code
# Fichier généré par Vitest
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
sonar.python.coverage.reportPaths=apps/backend/cov.info
+8 -3
View File
@@ -21,8 +21,13 @@ export const routes: Routes = [
path: 'sites/:siteId', path: 'sites/:siteId',
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => loadComponent: () =>
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then( import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
(m) => m.SiteDetailPlaceholder,
),
}, },
{
path: 'monitoring/sensors',
canActivate: [authGuard],
data: { role: 'admin' },
loadComponent: () =>
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
},
]; ];
@@ -64,4 +64,13 @@ describe('mockApiInterceptor', () => {
httpMock.expectNone(`${environment.apiUrl}/alerts`); httpMock.expectNone(`${environment.apiUrl}/alerts`);
expect((result as unknown[]).length).toBeGreaterThan(0); expect((result as unknown[]).length).toBeGreaterThan(0);
}); });
it('laisse toujours passer /predictions vers le réseau, même avec useMockFixtures activé', () => {
environment.useMockFixtures = true;
http.get(`${environment.apiUrl}/predictions`).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/predictions`);
req.flush({ timestamp: '2026-09-18T09:00:00Z', sites: [] });
});
}); });
@@ -26,5 +26,7 @@ export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
if (req.url.endsWith(`${environment.apiUrl}/alerts`)) { if (req.url.endsWith(`${environment.apiUrl}/alerts`)) {
return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE })); return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE }));
} }
// Volontairement jamais mocké, contrairement à `stats`/`alerts` : les prévisions sont servies
// par l'API réelle dès maintenant (au même titre que `/auth/*`, déjà toujours réel).
return next(req); return next(req);
}; };
@@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { PredictionsService } from './predictions.service';
import { environment } from '../../../environments/environment';
describe('PredictionsService', () => {
let service: PredictionsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(PredictionsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('appelle le bon endpoint et retourne un résumé de prévisions', () => {
let result: unknown;
service.getPredictions().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/predictions`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-18T09:00:00Z',
sites: [{ site_id: 'SITE001', site_name: 'Test', prediction: null }],
});
expect((result as { sites: unknown[] }).sites.length).toBe(1);
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { PredictionSummary } from '../../shared/models/prediction.model';
@Service()
export class PredictionsService {
private http = inject(HttpClient);
getPredictions() {
return this.http.get<PredictionSummary>(`${environment.apiUrl}/predictions`);
}
}
@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ReadingsService } from './readings.service';
import { environment } from '../../../environments/environment';
describe('ReadingsService', () => {
let service: ReadingsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(ReadingsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it("demande l'historique du site avec la fenêtre temporelle donnée", () => {
let result: unknown;
service
.getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z')
.subscribe((r) => (result = r));
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z');
expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z');
req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]);
expect((result as unknown[]).length).toBe(1);
});
it('ne pose pas de paramètres start/end quand ils sont omis', () => {
service.getHistory('SITE001').subscribe();
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
);
expect(req.request.params.has('start')).toBe(false);
expect(req.request.params.has('end')).toBe(false);
req.flush([]);
});
});
@@ -0,0 +1,20 @@
import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Reading } from '../../shared/models/reading.model';
@Service()
export class ReadingsService {
private http = inject(HttpClient);
getHistory(siteId: string, start?: string, end?: string) {
let params = new HttpParams().set('site_id', siteId);
if (start) {
params = params.set('start', start);
}
if (end) {
params = params.set('end', end);
}
return this.http.get<Reading[]>(`${environment.apiUrl}/readings`, { params });
}
}
@@ -0,0 +1,48 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { SensorsService } from './sensors.service';
import { environment } from '../../../environments/environment';
describe('SensorsService', () => {
let service: SensorsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(SensorsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it("appelle l'endpoint /sensors/status et retourne la réponse", () => {
let result: unknown;
service.getStatus().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-18T08:00:00',
sites: [
{
site_id: 'SITE001',
site_name: 'Test',
overall: 'ok',
sensors: {
consumption: { status: 'ok', since: null },
electrical: { status: 'ok', since: null },
temperature: { status: 'ok', since: null },
humidity: { status: 'ok', since: null },
network: { status: 'ok', since: null },
},
},
],
});
expect((result as { sites: unknown[] }).sites.length).toBe(1);
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import {SensorStatusResponse} from '../../shared/models/sensor-status.model';
@Service()
export class SensorsService {
private http = inject(HttpClient);
getStatus() {
return this.http.get<SensorStatusResponse>(`${environment.apiUrl}/sensors/status`);
}
}
@@ -38,4 +38,50 @@ describe('SitesService', () => {
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001'); expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
}); });
it('appelle le bon endpoint et retourne un site', () => {
let result: unknown;
service.getSite('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`);
expect(req.request.method).toBe('GET');
req.flush({
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
});
expect((result as { site_id: string }).site_id).toBe('SITE001');
});
it('appelle le bon endpoint et retourne la mesure courante du site', () => {
let result: unknown;
service.getCurrent('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-17T10:00:00Z',
site_id: 'SITE001',
site_type: 'industriel',
consumption_kw: 120,
consumption_kwh: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: 22,
humidity_percent: 55,
null_reasons: ['electrical_sensor_failure'],
data_quality: 'partial',
});
expect((result as { null_reasons: string[] }).null_reasons).toEqual([
'electrical_sensor_failure',
]);
});
}); });
@@ -2,6 +2,7 @@ import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { Site } from '../../shared/models/site.model'; import { Site } from '../../shared/models/site.model';
import { SiteCurrent } from '../../shared/models/site-current.model';
@Service() @Service()
export class SitesService { export class SitesService {
@@ -10,4 +11,12 @@ export class SitesService {
getSites() { getSites() {
return this.http.get<Site[]>(`${environment.apiUrl}/sites`); return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
} }
getSite(siteId: string) {
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
}
getCurrent(siteId: string) {
return this.http.get<SiteCurrent>(`${environment.apiUrl}/sites/${siteId}/current`);
}
} }
@@ -10,6 +10,9 @@
</div> </div>
</div> </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>
<ev-button <ev-button
class="logout-button" class="logout-button"
@@ -21,7 +24,13 @@
</div> </div>
</header> </header>
@if (error(); as message) { @if (statsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (alertsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (predictionsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
@@ -72,4 +81,33 @@
</ul> </ul>
</section> </section>
} }
@if (predictions().length > 0) {
<section class="predictions-section">
<h2>Prévisions de consommation</h2>
<ul class="predictions-list">
@for (site of predictions(); track site.site_id) {
<li class="prediction-item">
<span class="prediction-item__site">{{ site.site_name }}</span>
@if (site.prediction; as prediction) {
@if (prediction.status === 'available') {
<span class="prediction-item__value">
{{ prediction.predicted_value | number: '1.0-1' }} kWh
<span class="prediction-item__target"
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
>
</span>
} @else {
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
}}</ev-badge>
}
} @else {
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
}
</li>
}
</ul>
</section>
}
</div> </div>
@@ -68,6 +68,15 @@ h2 {
text-align: center; text-align: center;
} }
.card--link {
cursor: pointer;
transition: border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
}
.card__label { .card__label {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -121,3 +130,40 @@ h2 {
.alert-item__message { .alert-item__message {
font-size: 0.9rem; font-size: 0.9rem;
} }
.predictions-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prediction-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
}
.prediction-item__site {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__value {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__target {
margin-left: 0.35rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--color-text-muted);
}
@@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs';
import { Dashboard } from './dashboard'; import { 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 { 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'; import {Router, provideRouter} from '@angular/router';
@@ -17,18 +18,24 @@ vi.mock('chart.js', () => {
return { Chart: ChartMock, registerables: [] }; return { Chart: ChartMock, registerables: [] };
}); });
function predictionsMock(sites: unknown[] = []) {
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
}
describe('Dashboard', () => { describe('Dashboard', () => {
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it('charge les stats et les alertes au démarrage', async () => { it('charge les stats, les alertes et les prévisions au démarrage', async () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [Dashboard], imports: [Dashboard],
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]), provideRouter([]),
], ],
}); });
@@ -42,8 +49,12 @@ describe('Dashboard', () => {
expect(statsMock.getSummary).toHaveBeenCalled(); expect(statsMock.getSummary).toHaveBeenCalled();
expect(alertsMock.getAlerts).toHaveBeenCalled(); expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(predictions.getPredictions).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1); expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull(); expect(fixture.componentInstance.predictions().length).toBe(1);
expect(fixture.componentInstance.statsError()).toBeNull();
expect(fixture.componentInstance.alertsError()).toBeNull();
expect(fixture.componentInstance.predictionsError()).toBeNull();
}); });
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
@@ -61,6 +72,7 @@ describe('Dashboard', () => {
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]), provideRouter([]),
], ],
}); });
@@ -70,13 +82,13 @@ describe('Dashboard', () => {
vi.advanceTimersByTime(1); vi.advanceTimersByTime(1);
expect(statsMock.getSummary).toHaveBeenCalledTimes(1); expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.error()).not.toBeNull(); expect(fixture.componentInstance.statsError()).not.toBeNull();
expect(fixture.componentInstance.stats()).toBeNull(); expect(fixture.componentInstance.stats()).toBeNull();
vi.advanceTimersByTime(10000); vi.advanceTimersByTime(10000);
expect(statsMock.getSummary).toHaveBeenCalledTimes(2); expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.stats()).not.toBeNull(); expect(fixture.componentInstance.stats()).not.toBeNull();
expect(fixture.componentInstance.error()).toBeNull(); expect(fixture.componentInstance.statsError()).toBeNull();
}); });
it("n'interrompt pas la page quand le chargement des alertes échoue", () => { it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
@@ -88,6 +100,7 @@ describe('Dashboard', () => {
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]), provideRouter([]),
], ],
}); });
@@ -96,18 +109,78 @@ describe('Dashboard', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.alerts().length).toBe(0); expect(fixture.componentInstance.alerts().length).toBe(0);
expect(fixture.componentInstance.alertsError()).not.toBeNull();
});
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0);
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
});
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
vi.useFakeTimers();
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
// toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée.
vi.advanceTimersByTime(30000);
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
expect(fixture.componentInstance.statsError()).toBeNull();
}); });
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { 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 statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() }; const authMock = {
logout: vi.fn().mockReturnValue(of(undefined)),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [Dashboard], imports: [Dashboard],
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock }, { provide: AuthService, useValue: authMock },
provideRouter([]), provideRouter([]),
], ],
@@ -128,15 +201,17 @@ describe('Dashboard', () => {
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 statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; 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({ TestBed.configureTestingModule({
imports: [Dashboard], imports: [Dashboard],
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock }, { provide: AuthService, useValue: authMock },
provideRouter([]), provideRouter([]),
], ],
@@ -164,6 +239,7 @@ describe('Dashboard', () => {
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]), provideRouter([]),
], ],
}); });
@@ -179,4 +255,26 @@ describe('Dashboard', () => {
dashboard.badgeToneForSeverity('critical'), dashboard.badgeToneForSeverity('critical'),
); );
}); });
it('distingue le ton des statuts de prévision', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning');
expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger');
});
}); });
@@ -1,15 +1,17 @@
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe, DatePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { 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 { AlertsService } from '../../core/services/alerts.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 { Alert, AlertSeverity } from '../../shared/models/alert.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';
import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge';
@@ -27,11 +29,21 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
critical: 'critical', critical: 'critical',
}; };
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
// tomber sur `undefined` si ce statut apparaît un jour.
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
available: 'success',
insufficient_data: 'warning',
error: 'danger',
};
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
imports: [ imports: [
DecimalPipe, DecimalPipe,
DatePipe,
RouterLink, RouterLink,
ConsumptionGauge, ConsumptionGauge,
SiteLoadChart, SiteLoadChart,
@@ -47,31 +59,52 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
export class Dashboard implements OnInit { export class Dashboard implements OnInit {
private statsService = inject(StatsService); private statsService = inject(StatsService);
private alertsService = inject(AlertsService); private alertsService = inject(AlertsService);
private auth = inject(AuthService); public auth = inject(AuthService);
private predictionsService = inject(PredictionsService);
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[]>([]); alerts = signal<Alert[]>([]);
error = signal<string | null>(null); predictions = signal<SitePredictionSummary[]>([]);
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
statsError = signal<string | null>(null);
alertsError = signal<string | null>(null);
predictionsError = signal<string | null>(null);
ngOnInit(): void { ngOnInit(): void {
this.alertsService this.alertsService
.getAlerts() .getAlerts()
.pipe(catchError(() => this.reportUnavailable())) .pipe(catchError(() => this.reportUnavailable(this.alertsError)))
.subscribe((alerts) => this.alerts.set(alerts)); .subscribe((alerts) => {
this.alertsError.set(null);
this.alerts.set(alerts);
});
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService
.getPredictions()
.pipe(catchError(() => this.reportUnavailable(this.predictionsError)))
.subscribe((summary) => {
this.predictionsError.set(null);
this.predictions.set(summary.sites);
});
// Piège : le catchError porte sur l'observable interne. Sur le flux externe il // Piège : le catchError porte sur l'observable interne. Sur le flux externe il
// terminerait le timer, et le rafraîchissement ne repartirait jamais. // terminerait le timer, et le rafraîchissement ne repartirait jamais.
timer(0, REFRESH_INTERVAL_MS) timer(0, REFRESH_INTERVAL_MS)
.pipe( .pipe(
switchMap(() => switchMap(() =>
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
), ),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
) )
.subscribe((stats) => { .subscribe((stats) => {
this.error.set(null); this.statsError.set(null);
this.stats.set(stats); this.stats.set(stats);
}); });
} }
@@ -80,6 +113,10 @@ export class Dashboard implements OnInit {
return TON_PAR_SEVERITE[severity]; return TON_PAR_SEVERITE[severity];
} }
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
return TON_PAR_STATUT_PREDICTION[status];
}
onLogout(): void { onLogout(): void {
this.auth.logout().subscribe({ this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']), next: () => this.router.navigate(['/login']),
@@ -91,8 +128,8 @@ export class Dashboard implements OnInit {
}); });
} }
private reportUnavailable(): Observable<never> { private reportUnavailable(target: WritableSignal<string | null>): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE); target.set(UNAVAILABLE_MESSAGE);
return EMPTY; return EMPTY;
} }
} }
@@ -0,0 +1,49 @@
<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]) {
<li class="sensor-item">
<span
class="sensor-dot"
[class]="'sensor-dot--' + sensorOf(site.sensors, entry[0]).status"
></span>
<span class="sensor-item__label">{{ entry[1] }}</span>
@if (sensorOf(site.sensors, entry[0]).status === 'failing') {
<span class="sensor-item__since">
depuis {{ sensorOf(site.sensors, entry[0]).since | date: 'short' }}
</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,99 @@
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 depuis quand un capteur est 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('depuis');
});
});
@@ -0,0 +1,65 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, EMPTY, Observable } from 'rxjs';
import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge';
import {Card} from '../../../shared/components/ui/card/card';
import {Alert} from '../../../shared/components/ui/alert/alert';
import {Brand} from '../../../shared/components/ui/brand/brand';
import {SensorsService} from '../../../core/services/sensors.service';
import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model';
import { DatePipe } from '@angular/common';
const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.';
const SENSOR_LABELS: Record<string, string> = {
consumption: 'Consommation',
electrical: 'Électrique',
temperature: 'Température',
humidity: 'Humidité',
network: 'Réseau',
};
const TON_PAR_OVERALL: Record<string, BadgeTone> = {
ok: 'success',
degraded: 'warning',
critical: 'critical',
};
@Component({
selector: 'app-sensor-status',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe],
templateUrl: './sensor-status.html',
styleUrl: './sensor-status.scss',
})
export class SensorStatusView implements OnInit {
private sensorsService = inject(SensorsService);
data = signal<SensorStatusResponse | null>(null);
error = signal<string | null>(null);
readonly sensorEntries = Object.entries(SENSOR_LABELS);
ngOnInit(): void {
this.sensorsService
.getStatus()
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((response) => {
this.error.set(null);
this.data.set(response);
});
}
sensorOf(sensors: Record<string, SensorDiagnostic>, key: string): SensorDiagnostic {
return sensors[key];
}
badgeToneForOverall(overall: string): BadgeTone {
return TON_PAR_OVERALL[overall] ?? 'neutral';
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -1,19 +0,0 @@
<div class="site-detail-placeholder">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
<span>/</span>
<a routerLink="/sites">Sites</a>
</nav>
<header class="site-detail-placeholder__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-detail-placeholder__logo" />
</a>
<h1>Site {{ siteId() }}</h1>
</header>
<ev-card>
<p>Le détail de ce site est à venir (voir issue #51).</p>
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</ev-card>
</div>
@@ -1,28 +0,0 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 640px;
margin: 0 auto;
}
.site-detail-placeholder__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 1.5rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
}
.site-detail-placeholder__logo {
font-size: 1.3rem;
}
ev-card p {
margin: 0 0 0.75rem;
}
@@ -1,42 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { SiteDetailPlaceholder } from './site-detail-placeholder';
describe('SiteDetailPlaceholder', () => {
it("affiche l'identifiant du site depuis la route", () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE001');
});
it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE002');
expect(fixture.nativeElement.textContent).not.toContain('SITE001');
});
});
@@ -1,19 +0,0 @@
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { Card } from '../../../shared/components/ui/card/card';
import { Brand } from '../../../shared/components/ui/brand/brand';
@Component({
selector: 'app-site-detail-placeholder',
standalone: true,
imports: [RouterLink, Card, Brand],
templateUrl: './site-detail-placeholder.html',
styleUrl: './site-detail-placeholder.scss',
})
export class SiteDetailPlaceholder {
private route = inject(ActivatedRoute);
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId'))));
}
@@ -0,0 +1,85 @@
<div class="site-detail">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
<span>/</span>
<a routerLink="/sites">Sites</a>
</nav>
<header class="site-detail__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-detail__logo" />
</a>
<div>
<h1>{{ site()?.site_name ?? siteId() }}</h1>
@if (site(); as s) {
<p class="site-detail__subtitle">
{{ s.site_type }} · {{ s.location || 'Localisation inconnue' }}
</p>
}
</div>
<div class="site-detail__badges">
@if (site(); as s) {
<ev-badge [tone]="badgeToneForStatus(s.status)">{{ s.status ?? '-' }}</ev-badge>
}
@if (hasMeasurement() && qualityLabel(); as label) {
<ev-badge [tone]="qualityTone()">{{ label }}</ev-badge>
}
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (site(); as s) {
@if (hasMeasurement()) {
<section class="overview">
<ev-card class="card card--gauge">
<span class="card__label">Consommation vs capacité</span>
@let consumption = consumptionKw();
@if (consumption !== null) {
<app-consumption-gauge [consumption]="consumption" [capacity]="s.capacity_kw ?? 0" />
<span class="card__value">
{{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW
</span>
} @else {
<p class="card__unavailable">
Consommation indisponible
<span class="metric__reason">({{ consumptionReason() }})</span>
</p>
}
</ev-card>
<ev-card class="metrics-card">
<span class="card__label">Mesure instantanée</span>
<dl class="metrics-grid">
@for (metric of metrics(); track metric.key) {
<div class="metric">
<dt>{{ metric.label }}</dt>
@if (metric.value !== null) {
<dd>{{ metric.value }}</dd>
} @else {
<dd class="metric__unavailable">
Indisponible
<span class="metric__reason">({{ metric.reason }})</span>
</dd>
}
</div>
}
</dl>
</ev-card>
</section>
@if (history().length > 0) {
<section class="chart-section">
<h2>Historique de consommation</h2>
<app-reading-history-chart [readings]="history()" />
</section>
}
} @else {
<ev-alert severity="warning" class="banner-empty">{{ noMeasurementMessage }}</ev-alert>
}
}
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</div>
@@ -0,0 +1,119 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.site-detail__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.site-detail__logo {
font-size: 1.3rem;
}
.site-detail__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.site-detail__badges {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
}
.banner-empty {
display: block;
margin: 0 0 1.5rem;
}
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
}
.card {
padding: 1.25rem;
gap: 0.35rem;
}
.card--gauge {
align-items: center;
text-align: center;
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.card__value {
font-size: 1.6rem;
font-weight: 700;
}
.card__unavailable {
color: var(--color-text-muted);
margin: 0;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem 1.5rem;
margin: 0.5rem 0 0;
}
.metric {
dt {
font-size: 0.75rem;
color: var(--color-text-muted);
}
dd {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
}
.metric__unavailable {
color: var(--color-text-muted);
font-weight: 400;
}
.metric__reason {
font-size: 0.8rem;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 1rem;
}
.chart-section {
margin-bottom: 2rem;
}
@@ -0,0 +1,281 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { SiteDetail } from './site-detail';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
const SITE = {
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
};
const CURRENT_COMPLET = {
timestamp: '2026-09-17T10:00:00Z',
site_id: 'SITE001',
site_type: 'industriel',
consumption_kw: 120,
consumption_kwh: null,
voltage_v: 230,
current_a: 12,
power_factor: 0.95,
temperature_celsius: 22,
humidity_percent: 55,
null_reasons: [] as string[],
data_quality: 'good' as const,
};
const SANS_MESURE = {
...CURRENT_COMPLET,
timestamp: null,
consumption_kw: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: null,
humidity_percent: null,
data_quality: 'critical' as const,
};
const LECTURE = {
reading_id: 1,
site_id: 'SITE001',
timestamp: '2026-09-17T09:00:00Z',
source: 'api_history' as const,
consumption_kw: 118,
consumption_kwh: null,
consumption_euros: null,
voltage_v: 230,
current_a: 12,
power_factor: 0.95,
temperature_celsius: 22,
humidity_percent: 55,
solar_irradiance_wm2: null,
is_working_hours: true,
data_quality: 'good' as const,
null_reasons: null,
imputed_values: null,
imputation_method: null,
};
function setup(
siteId: string,
sitesMock: Partial<SitesService>,
readingsMock: Partial<ReadingsService>,
) {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
TestBed.configureTestingModule({
imports: [SiteDetail],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
{ provide: SitesService, useValue: sitesMock },
{ provide: ReadingsService, useValue: readingsMock },
],
});
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
}
describe('SiteDetail', () => {
it('charge le site, la mesure courante et son historique au démarrage', () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
expect(fixture.componentInstance.current()?.consumption_kw).toBe(120);
expect(fixture.componentInstance.history().length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
});
it("signale l'indisponibilité quand un des appels échoue", () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.site()).toBeNull();
});
it('efface les données du site précédent quand le chargement du suivant échoue', () => {
const getSite = vi
.fn()
.mockReturnValueOnce(of(SITE))
.mockReturnValueOnce(throwError(() => new Error('404')));
const { fixture, paramMap } = setup(
'SITE001',
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.site()).toBeNull();
expect(fixture.componentInstance.current()).toBeNull();
expect(fixture.componentInstance.history()).toEqual([]);
expect(fixture.nativeElement.textContent).not.toContain('Site 1');
});
it('interroge le site et sa mesure courante en parallèle', () => {
const getSite = vi.fn().mockReturnValue(of(SITE));
const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET));
const { fixture } = setup(
'SITE001',
{ getSite, getCurrent },
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
expect(getSite).toHaveBeenCalledWith('SITE001');
expect(getCurrent).toHaveBeenCalledWith('SITE001');
});
it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => {
const sansConsommation = {
...CURRENT_COMPLET,
consumption_kw: null,
null_reasons: ['consumption_sensor_failure'],
data_quality: 'partial' as const,
};
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(sansConsommation)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.consumptionKw()).toBeNull();
expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne');
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Consommation indisponible');
});
it('trace la jauge pour une consommation nulle réellement mesurée', () => {
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW');
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible');
});
it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => {
const partielle = {
...CURRENT_COMPLET,
voltage_v: null,
current_a: null,
power_factor: null,
null_reasons: ['electrical_sensor_failure'],
data_quality: 'partial' as const,
};
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(partielle)),
},
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
);
fixture.detectChanges();
const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v');
expect(tension?.value).toBeNull();
expect(tension?.reason).toBe('capteur électrique en panne');
const texte = fixture.nativeElement.textContent;
expect(texte).toContain('Indisponible');
expect(texte).toContain('capteur électrique en panne');
expect(texte).toContain('Données partielles');
});
it('recharge les données quand le paramètre de route siteId change', () => {
const getSite = vi.fn().mockReturnValue(of(SITE));
const { fixture, paramMap } = setup(
'SITE001',
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(getSite).toHaveBeenCalledWith('SITE002');
});
it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory },
);
fixture.detectChanges();
expect(getHistory).toHaveBeenCalledWith(
'SITE001',
'2026-09-16T10:00:00.000Z',
'2026-09-17T10:00:00Z',
);
});
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)),
},
{ getHistory },
);
fixture.detectChanges();
expect(getHistory).not.toHaveBeenCalled();
expect(fixture.componentInstance.hasMeasurement()).toBe(false);
expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.');
});
});
@@ -0,0 +1,213 @@
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
import { Site } from '../../../shared/models/site.model';
import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model';
import { SiteCurrent } from '../../../shared/models/site-current.model';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000;
const TON_PAR_STATUT: Record<string, BadgeTone> = {
actif: 'success',
maintenance: 'warning',
hors_service: 'danger',
};
const TON_PAR_QUALITE: Record<ReadingDataQuality, BadgeTone> = {
good: 'success',
partial: 'warning',
degraded: 'danger',
critical: 'critical',
};
const LIBELLE_PAR_QUALITE: Record<ReadingDataQuality, string> = {
good: 'Données complètes',
partial: 'Données partielles',
degraded: 'Données dégradées',
critical: 'Données critiques',
};
type MetricKey =
| 'consumption_kw'
| 'voltage_v'
| 'current_a'
| 'power_factor'
| 'temperature_celsius'
| 'humidity_percent';
interface MetricDef {
key: MetricKey;
label: string;
format: (value: number) => string;
}
const CONSUMPTION_DEF: MetricDef = {
key: 'consumption_kw',
label: 'Consommation',
format: (v) => `${v.toFixed(1)} kW`,
};
const METRIC_DEFS: MetricDef[] = [
CONSUMPTION_DEF,
{ key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` },
{ key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` },
{ key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) },
{ key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` },
{ key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` },
];
// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ;
// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide.
const RAISONS_PAR_CHAMP: Record<MetricKey, string[]> = {
consumption_kw: ['consumption_sensor_failure', 'network_loss'],
voltage_v: ['electrical_sensor_failure', 'network_loss'],
current_a: ['electrical_sensor_failure', 'network_loss'],
power_factor: ['electrical_sensor_failure', 'network_loss'],
temperature_celsius: ['temperature_sensor_failure', 'network_loss'],
humidity_percent: ['humidity_sensor_failure', 'network_loss'],
};
const LIBELLE_PAR_RAISON: Record<string, string> = {
consumption_sensor_failure: 'capteur de consommation en panne',
electrical_sensor_failure: 'capteur électrique en panne',
temperature_sensor_failure: 'capteur de température en panne',
humidity_sensor_failure: "capteur d'humidité en panne",
network_loss: 'perte réseau',
};
export interface MetricView {
key: MetricKey;
label: string;
value: string | null;
reason: string;
}
@Component({
selector: 'app-site-detail',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
templateUrl: './site-detail.html',
styleUrl: './site-detail.scss',
})
export class SiteDetail {
private route = inject(ActivatedRoute);
private sitesService = inject(SitesService);
private readingsService = inject(ReadingsService);
private destroyRef = inject(DestroyRef);
readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE;
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? '')));
site = signal<Site | null>(null);
current = signal<SiteCurrent | null>(null);
history = signal<Reading[]>([]);
error = signal<string | null>(null);
hasMeasurement = computed(() => this.current()?.timestamp != null);
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
consumptionLabel = computed(() => {
const kw = this.consumptionKw();
return kw != null ? CONSUMPTION_DEF.format(kw) : null;
});
consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current()));
qualityLabel = computed(() => {
const quality = this.current()?.data_quality;
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
});
qualityTone = computed<BadgeTone>(() => {
const quality = this.current()?.data_quality;
return quality ? TON_PAR_QUALITE[quality] : 'neutral';
});
metrics = computed<MetricView[]>(() => {
const current = this.current();
return METRIC_DEFS.map((def) => {
const valeur = current ? current[def.key] : null;
return {
key: def.key,
label: def.label,
value: valeur != null ? def.format(valeur) : null,
reason: valeur == null ? this.reasonFor(def.key, current) : '',
};
});
});
constructor() {
toObservable(this.siteId)
.pipe(
filter((siteId): siteId is string => !!siteId),
// Piège : switchMap sur le flux externe annule le chargement en cours dès qu'un
// nouveau siteId arrive, sinon une réponse en retard peut écraser le site affiché.
switchMap((siteId) => this.load(siteId)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((result) => {
this.error.set(null);
this.site.set(result.site);
this.current.set(result.current);
this.history.set(result.history);
});
}
badgeToneForStatus(status: string | null): BadgeTone {
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
}
private load(siteId: string) {
return forkJoin({
site: this.sitesService.getSite(siteId),
current: this.sitesService.getCurrent(siteId),
}).pipe(
switchMap(({ site, current }) =>
this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))),
),
catchError(() => this.reportUnavailable()),
);
}
private loadHistory(siteId: string, current: SiteCurrent): Observable<Reading[]> {
// Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la
// dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide.
const end = current.timestamp;
if (end === null) {
return of([]);
}
const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString();
return this.readingsService.getHistory(siteId, start, end);
}
private reasonFor(field: MetricKey, current: SiteCurrent | null): string {
const raisons = RAISONS_PAR_CHAMP[field];
const trouvees = (current?.null_reasons ?? [])
.filter((raison) => raisons.includes(raison))
.map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison);
return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue';
}
// Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé
// sous le bandeau et laisse lire les chiffres de A en croyant regarder B.
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
this.site.set(null);
this.current.set(null);
this.history.set([]);
return EMPTY;
}
}
@@ -0,0 +1 @@
<canvas #canvas></canvas>
@@ -0,0 +1,4 @@
:host {
display: block;
height: 260px;
}
@@ -0,0 +1,101 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { Chart } from 'chart.js';
import { ReadingHistoryChart } from './reading-history-chart';
vi.mock('chart.js', () => {
class ChartMock {
static instances: ChartMock[] = [];
static register = vi.fn();
update = vi.fn();
destroy = vi.fn();
data: { labels?: unknown[]; datasets: Record<string, unknown>[] } = { datasets: [{}] };
constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) {
if (config?.data) {
this.data = config.data;
}
ChartMock.instances.push(this);
}
}
return { Chart: ChartMock, registerables: [] };
});
type ChartDouble = {
destroy: ReturnType<typeof vi.fn>;
data: { labels?: unknown[]; datasets: Record<string, unknown>[] };
};
function lastChart(): ChartDouble | undefined {
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
}
const READING = {
reading_id: 1,
site_id: 'S1',
timestamp: '2026-09-17T10:00:00Z',
source: 'api_history' as const,
consumption_kw: 42,
consumption_kwh: null,
consumption_euros: null,
voltage_v: null,
current_a: null,
power_factor: null,
temperature_celsius: null,
humidity_percent: null,
solar_irradiance_wm2: null,
is_working_hours: null,
data_quality: 'good' as const,
null_reasons: null,
imputed_values: null,
imputation_method: null,
};
describe('ReadingHistoryChart', () => {
it('se crée sans erreur avec une liste de lectures valide', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
expect(() => fixture.detectChanges()).not.toThrow();
});
it('met à jour le graphique quand les lectures changent après initialisation', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
fixture.detectChanges();
fixture.componentRef.setInput('readings', [
{ ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const },
]);
fixture.detectChanges();
expect(() => fixture.detectChanges()).not.toThrow();
});
it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
// L'API trie en timestamp décroissant : le composant doit rétablir la chronologie.
fixture.componentRef.setInput('readings', [
{ ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 },
{ ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 },
]);
fixture.detectChanges();
const chart = lastChart();
expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']);
expect(chart?.data.datasets[0]['data']).toEqual([42, 60]);
});
it('détruit le graphique quand le composant est détruit', () => {
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
const fixture = TestBed.createComponent(ReadingHistoryChart);
fixture.componentRef.setInput('readings', [READING]);
fixture.detectChanges();
const chart = lastChart();
fixture.destroy();
expect(chart?.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,95 @@
import {
Component,
ElementRef,
ViewChild,
input,
effect,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { Chart, registerables } from 'chart.js';
import { Reading, ReadingDataQuality } from '../../models/reading.model';
Chart.register(...registerables);
const QUALITY_COLORS: Record<ReadingDataQuality, string> = {
good: '#3b82f6',
partial: '#f9a825',
degraded: '#ef6c00',
critical: '#c62828',
};
const UNKNOWN_QUALITY_COLOR = '#9ca3af';
interface ChartSeries {
labels: string[];
values: number[];
colors: string[];
}
// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history
// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours.
function toSeries(readings: Reading[]): ChartSeries {
const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
return {
labels: ordered.map((r) => r.timestamp),
values: ordered.map((r) => r.consumption_kw ?? 0),
colors: ordered.map((r) =>
r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR,
),
};
}
@Component({
selector: 'app-reading-history-chart',
standalone: true,
templateUrl: './reading-history-chart.html',
styleUrl: './reading-history-chart.scss',
})
export class ReadingHistoryChart implements AfterViewInit, OnDestroy {
readings = input.required<Reading[]>();
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
private chart?: Chart<'line'>;
constructor() {
effect(() => {
const series = toSeries(this.readings());
if (this.chart) {
this.chart.data.labels = series.labels;
this.chart.data.datasets[0].data = series.values;
this.chart.data.datasets[0].pointBackgroundColor = series.colors;
this.chart.update('none');
}
});
}
ngAfterViewInit(): void {
const series = toSeries(this.readings());
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'line',
data: {
labels: series.labels,
datasets: [
{
data: series.values,
borderColor: '#3b82f6',
pointBackgroundColor: series.colors,
tension: 0.25,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } },
},
},
});
}
ngOnDestroy(): void {
this.chart?.destroy();
}
}
@@ -0,0 +1,24 @@
export type PredictionStatus = 'available' | 'insufficient_data' | 'error';
export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw';
export interface SitePrediction {
target_at: string;
target_metric: PredictionTargetMetric;
period_minutes: number | null;
predicted_value: number | null;
status: PredictionStatus;
failure_reason: string | null;
model_reference: string;
created_at: string;
}
export interface SitePredictionSummary {
site_id: string;
site_name: string;
prediction: SitePrediction | null;
}
export interface PredictionSummary {
timestamp: string;
sites: SitePredictionSummary[];
}
@@ -0,0 +1,23 @@
export type ReadingSource = 'csv' | 'api_current' | 'api_history';
export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical';
export interface Reading {
reading_id: number;
site_id: string;
timestamp: string;
source: ReadingSource;
consumption_kw: number | null;
consumption_kwh: number | null;
consumption_euros: string | null;
voltage_v: number | null;
current_a: number | null;
power_factor: number | null;
temperature_celsius: number | null;
humidity_percent: number | null;
solar_irradiance_wm2: number | null;
is_working_hours: boolean | null;
data_quality: ReadingDataQuality | null;
null_reasons: string[] | null;
imputed_values: Record<string, unknown> | null;
imputation_method: string | null;
}
@@ -0,0 +1,28 @@
export type SensorStatus = 'ok' | 'failing';
export type OverallStatus = 'ok' | 'degraded' | 'critical';
export interface SensorDiagnostic {
status: SensorStatus;
since: string | null;
}
export interface SiteSensors {
consumption: SensorDiagnostic;
electrical: SensorDiagnostic;
temperature: SensorDiagnostic;
humidity: SensorDiagnostic;
network: SensorDiagnostic;
[key: string]: SensorDiagnostic;
}
export interface SiteSensorStatus {
site_id: string;
site_name: string;
sensors: SiteSensors;
overall: OverallStatus;
}
export interface SensorStatusResponse {
timestamp: string;
sites: SiteSensorStatus[];
}
@@ -0,0 +1,16 @@
import { ReadingDataQuality } from './reading.model';
export interface SiteCurrent {
timestamp: string | null;
site_id: string;
site_type: string;
consumption_kw: number | null;
consumption_kwh: number | null;
voltage_v: number | null;
current_a: number | null;
power_factor: number | null;
temperature_celsius: number | null;
humidity_percent: number | null;
null_reasons: string[];
data_quality: ReadingDataQuality;
}
@@ -1,5 +1,5 @@
export const environment = { export const environment = {
production: false, production: false,
apiUrl: '/api/v1', apiUrl: '/api/v1',
useMockFixtures: true, // a passer a false une fois le backend prêt useMockFixtures: false,
}; };
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.{ts,tsx,js,jsx}'],
exclude: [
'**/*.spec.*',
'**/*.test.*',
'**/node_modules/**',
'**/dist/**',
],
},
},
})
+3 -3
View File
@@ -74,10 +74,10 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|---|---|---|---|---| |---|---|---|---|---|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) | | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement (features par lags/moyennes glissantes, baseline de persistance saisonnière, suivi MLflow local), voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Scoring, endpoint et surveillance de dérive pas encore construits | | ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites |
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
+44 -14
View File
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid ```mermaid
flowchart TB flowchart TB
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, sensors"] ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors, predictions"]
sc["schemas<br/>Pydantic"] sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, SensorService"] sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService, PredictionService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading"] rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading, prediction"]
md["models<br/>10 tables"] md["models<br/>10 tables"]
db[("PostgreSQL")] db[("PostgreSQL")]
@@ -142,25 +142,29 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/sites/{site_id}/current` | Dernière mesure d'un site. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 | | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 |
| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | | GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 |
| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 |
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier
`openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent. `openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent.
**Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. **Sept routes du contrat sont publiques** : les deux sondes, `/auth/login`, `/auth/logout`,
`/auth/forgot-password` et les deux routes de réinitialisation, qui portent leur autorisation dans
le jeton à usage unique plutôt que dans un `Principal`.
`tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et `tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
donc de modifier la liste dans ce fichier de test. donc de modifier `ROUTES_PUBLIQUES` dans `tests/api/acces.py`.
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour `GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches `GET /alerts` puis pour les suivantes (`dataset`) : les quatre couches
`endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées `endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées
par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux
routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une
@@ -171,9 +175,25 @@ et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la
elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET
/stats/summary` et `GET /sensors/status` agrègent chacune deux repositories (`SiteRepository`, /stats/summary` et `GET /sensors/status` agrègent chacune deux repositories (`SiteRepository`,
`ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elles n'entrent donc `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elles n'entrent donc
pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est dans pas dans ce gabarit route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`,
mais `SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la
dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de mesure
à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. Le contrat
détaillé pour le frontend est dans
[31-contrat-authentification.md](31-contrat-authentification.md). [31-contrat-authentification.md](31-contrat-authentification.md).
`GET /predictions` reprend ce même sous-gabarit « dernière valeur par site » (`SiteRepository` +
`PredictionRepository`, un `SitePredictionSummaryResponse` par site plutôt qu'une table brute).
Différence avec `stats`/`sensors` : `prediction` est une vraie table accumulée par un processus
externe (`enervision_ml.score`, cf. `ml/README.md`), pas une valeur recalculée à la volée depuis
`reading` à chaque appel. `PredictionRepository.latest_by_site()` isole donc un `DISTINCT ON
(site_id)` ordonné par `target_at DESC` (couvert par l'index `ix_prediction_site_target`), le même
mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend `prediction: null`
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais
LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf.
[ML-START.md](../../ML-START.md) section 3.
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, `GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/
owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni
@@ -262,17 +282,22 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
### Ajouter une route métier ### Ajouter une route métier
Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/ Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/
`readings`/`sensors` (`dataset`, `prediction`) : `readings`/`sensors`/`predictions` (`dataset`) :
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` 1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN`
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
(404, 409, ...) directement sur l'endpoint qui les rend. (404, 409, ...) directement sur l'endpoint qui les rend.
2. Décrire son tag dans `TAGS`. 2. Décrire son tag dans `TAGS`.
3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à 3. **La classer dans `tests/api/acces.py`** : `ROLE_MINIMUM` avec son rôle minimum si elle passe
`ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`, par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), `ROUTES_SANS_ROLE` si elle se
l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** : contente de `CurrentPrincipalDep`, `ROUTES_PUBLIQUES` si elle est ouverte. L'oubli n'est plus
une route oubliée n'y est pas détectée automatiquement. silencieux : `test_every_declared_route_is_classified` échoue sur une route non classée comme
4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`. sur une entrée qui ne correspond plus à aucune route. `ROUTES_A_ROLE` de `test_openapi.py` en
est dérivée, et `test_matrice_acces.py` vérifie le niveau réellement monté.
4. Si elle passe par `require_trusted_origin`, l'ajouter à `ORIGINE_VERIFIEE` dans
`tests/api/test_openapi.py`. **Cette liste-là reste maintenue à la main.**
5. `make openapi`, puis `uv run pytest tests/api/test_openapi.py tests/api/test_route_protection.py
tests/api/test_matrice_acces.py`.
## Sécurité ## Sécurité
@@ -323,9 +348,14 @@ Le reste, par ordre de surface :
Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md).
Trois fichiers méritent d'être connus avant de toucher à l'authentification : Quatre fichiers méritent d'être connus avant de toucher à l'authentification :
- `tests/api/acces.py` : la classification des routes, `ROUTES_PUBLIQUES` et `ROLE_MINIMUM` en
tête. Ce n'est pas un test, c'est la référence que les deux suivants confrontent au
comportement observé.
- `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut. - `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut.
- `tests/api/test_matrice_acces.py` : chaque route gardée croisée avec chacun des trois rôles,
dans les deux sens, puis rejouée sous `integration` avec de vrais jetons.
- `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les - `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les
deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro
appel quand la limite est atteinte. appel quand la limite est atteinte.
+25 -19
View File
@@ -13,24 +13,29 @@ Ce qui est en place :
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
`provideHttpClient(withInterceptors([mockApiInterceptor]))`. `provideHttpClient(withInterceptors([mockApiInterceptor]))`.
- Une route `/dashboard` en composant différé, et une redirection depuis la racine. - Une route `/dashboard` en composant différé, et une redirection depuis la racine.
- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de - `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et
fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification
(jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`,
`features/dashboard` la page principale, `shared/components` la jauge de consommation et le
graphique de charge par site, tous deux construits sur Chart.js. graphique de charge par site, tous deux construits sur Chart.js.
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail :
[31-contrat-authentification.md](31-contrat-authentification.md).
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
`ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
réutiliser plutôt que redéfinir ses propres styles. Détail : réutiliser plutôt que redéfinir ses propres styles. Détail :
[32-design-systeme-frontend.md](32-design-systeme-frontend.md). [32-design-systeme-frontend.md](32-design-systeme-frontend.md).
- L'état vit dans des signaux, sans bibliothèque dédiée. - L'état vit dans des signaux, sans bibliothèque dédiée.
- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. - Vitest via le builder `@angular/build:unit-test`, couverture activée.
- Prettier configuré, parser `angular` pour les gabarits HTML. - Prettier configuré, parser `angular` pour les gabarits HTML.
Ce qui n'existe pas encore : Ce qui n'existe pas encore :
- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre. sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
les routes métier de l'API en exigent un. Voir `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette
[31-contrat-authentification.md](31-contrat-authentification.md). PR (voir plus bas).
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
- Aucun lint : ESLint n'est pas installé. - Aucun lint : ESLint n'est pas installé.
@@ -84,19 +89,19 @@ sequenceDiagram
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si `mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il
suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide
et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche.
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
`apiUrl` relatif, `/api/v1`. `apiUrl` relatif, `/api/v1`.
En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif
le fichier via `fileReplacements`, et la configuration `production` est celle par défaut. (`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur
`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...`
**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production, sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le
pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement : backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md).
telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier
déploiement, en même temps que sera tranchée la question de l'ingress dans
[10-infra.md](10-infra.md).
## Exécution ## Exécution
@@ -124,9 +129,10 @@ avec un service statique, il reste à écrire.
## Sécurité ## Sécurité
- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL.
- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun - L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et
intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
que les fixtures. rafraîchissement sur 401. Détail complet dans
[31-contrat-authentification.md](31-contrat-authentification.md).
## Tests ## Tests
@@ -0,0 +1,45 @@
# Revue 1 - Cadrage (Jalon 2 - 15/09)
Présents :
- Dorian
- Johan
- Inès
- Meryem
- Valentin
**Objectif:** Valider le périmètre retenu et les choix technologiques initiaux.
## KPIs
| Indicateur | Valeur |
| --- | --- |
| Issues fermées / Issues totales | 4/4 |
| Must fermés / Must total | 4/41 |
| Jours écoulés / jours restants | 2/9 |
## Issues du jalon
| Issue | Titre | Labels | Fermée par |
| --- | --- | --- | --- |
| #58 | Préparer les tests unitaires du frontend pour permettre leur implémentation continue | frontend, test | Dorian |
| #57 | Préparer les tests unitaires du backend pour permettre leur implémentation continue | backend, test | Dorian |
| #18 | Provisionner un cluster K8s single-node (k3s) via Terraform | infra | Dorian |
| #67 | docs: fonder la documentation d'architecture du monorepo | documentation | Johan |
## Décisions prises
| Décision | Porteur | Justification |
| --- | --- | --- |
| Vitest retenu comme framework de tests unitaires frontend (PR #62) | Valentin | Standard de l'écosystème Vite ; permet scripts npm et mesure de couverture dès maintenant. |
| pytest / pytest-asyncio + coverage.py côté backend, seuil de couverture fixé à 85 % (PR #65) | Johan | Le code applicatif est encore un squelette : c'est le bon moment pour poser les conventions de test avant l'arrivée de la logique métier. |
| Cluster K8s single-node (k3s) provisionné via Terraform, version k3s figée en v1.31.5+k3s1, option `--write-kubeconfig-mode 644` retirée (PR #66) | Dorian | Reproductibilité de l'infra on-premise (infra as code) et durcissement sécurité du kubeconfig. |
| Documentation d'architecture en 5 vues Mermaid + index dans `docs/architecture/`, avec statuts Done/In Progress/Target (PR #68) | Johan | Rendu natif GitHub sans assets binaires ; corrige des affirmations obsolètes (frontend, port 4200, arborescence). |
## Actions à mener
| Action | Responsable | Échéance |
| --- | --- | --- |
| Alimenter les tests unitaires frontend au fil des features, sur le socle Vitest posé en #58/#62 | Valentin | J4 (22/09) |
| Implémenter la logique métier backend en respectant la structure de tests (`tests/api`, `tests/db`, `tests/core`, `tests/services`, `tests/repositories`) | Johan | J3 (18/09) |
| Maintenir les vues d'architecture (`docs/architecture/`) à jour à chaque PR modifiant un composant, comme prévu par la règle posée en #67/#68 | Équipe | Continu |
| Brancher `make test` / `make dev` / `make check` dans la CI | Dorian | J3 (18/09) |
+112
View File
@@ -0,0 +1,112 @@
# Point d'avancement · ProjetPiscine_EnerVision · 2026-09-18
Rendu final : **vendredi 25 septembre (J10)**, 9h00 pour le ZIP des livrables et le support EC02.
Source : issues et PR GitHub du repo, branche `dev`. Relevé du 18/09 au matin.
## 1. Ce qui a bougé depuis le point d'hier
Grosse journée : **10 issues fermées**, **8 PR mergées sur `dev`**, **41 commits**.
| Auteur | Commits sur `dev` le 17/09 | Ce qui est arrivé |
|---|---|---|
| **Johan** | 27 | Design système front (#91), politique de mot de passe + mot de passe oublié (#87), vue liste des sites (#49), `GET /sensors/status` (#32), pipeline CI lint+tests (#20, avec Ines) |
| **Meryem** | 6 | Pipeline d'import des données historiques, **#14 fermée** (elle bloquait #15 et #16) |
| **Dorian (phyri0s)** | 3 | `GET /readings` avec fenêtre bornée et pagination, **pipeline d'entraînement LightGBM** posé (#92, ADR 0005) |
| **Valentin** | 4 | Auth frontend mergée (#7), Dependabot (#40), audit de sécu des dépendances en CI |
| **Ines** | 1 | Merge du pipeline CI, config SonarQube |
Le choix ML (#89) a été acté et fermé par tout le monde : **LightGBM, un modèle global**, documenté
dans `docs/adr/0005-modele-prediction-lightgbm.md`. Le module `ml/` existe sur `dev` avec
`data.py`, `features.py`, `train.py`, `baseline.py`, `metrics.py` et 4 fichiers de tests.
Décompte global : **33 issues fermées sur 65** (hier : 25/59). 6 issues créées dans la journée,
32 encore ouvertes, dont **22 sans personne dessus** (hier : 27).
## 2. Où en est chacun
| Membre | Issues ouvertes assignées | Détail |
|---|---|---|
| **Johan** | #19, #29, #51, #61 | Reverse proxy Nginx+TLS · endpoint `current` (PR #84 ouverte depuis le 16/09) · vue détail d'un site (PR #103, CI verte) · tests d'accès rôles |
| **ValentinDeFaria** | #53, #96, #97 | Vue supervision des capteurs (prise hier) · audit dépendances CI (PR #100) · couverture tests front CI (PR #101) |
| **phyri0s (Dorian)** | #5, #30 | Jeu de données d'entraînement simulé · #30 `/readings` **est livrée (PR #94 mergée) mais l'issue n'a pas été fermée** |
| **ineszang** | #21 | Pipeline CD (déploiement SSH) · PR #99 SonarQube approuvée mais check en échec |
| **Meryemel-gham** | **aucune** | #14 fermée hier à 10h11, rien de repris depuis |
| **Non assigné** | **22 issues** | Voir sections 4 et 5 |
## 3. Avancement par jalon
| Jalon | Échéance | Fermées / total | Hier | Statut |
|---|---|---|---|---|
| J1 - Environnement & repo | 14/09 | 5/5 | 5/5 | ✅ |
| J2 - Périmètre & choix techos | 15/09 | 4/4 | 4/4 | ✅ |
| **J3 - Ingestion & backend** | **18/09 (aujourd'hui)** | 9/15 | 6/12 | 🔴 60 %, **échoit ce soir avec 6 ouvertes** |
| **J4 - Architecture, sécurité, frontend** | **21/09 (J+3)** | 10/30 | 6/26 | 🔴 33 %, 20 ouvertes |
| **J5 - Robustesse & livrables** | **23/09 (J+5)** | 1/5 | 0/5 | 🔴 20 %, 4 ouvertes, **0 assignée** |
La journée d'hier a fait gagner 10 points sur J3 et 10 sur J4, mais le périmètre a aussi grossi
(59 → 65 issues). **Le retard identifié hier n'est pas résorbé, il est stabilisé.**
## 4. J3 échoit aujourd'hui : les 6 issues restantes
| Issue | Qui | État réel |
|---|---|---|
| #5 Jeu de données d'entraînement simulé | Dorian | En cours, socle posé hier |
| **#6 Entraîner le modèle** | **personne** | Le pipeline existe (#92), il manque le porteur de l'entraînement effectif |
| **#15 DAG Airflow - normalisation** | **personne** | **Débloquée hier** par la fermeture de #14 |
| **#16 DAG Airflow - chargement micro-batch** | **personne** | **Débloquée hier** par la fermeture de #14 |
| #44 Tests unitaires modèle/recommandations | **personne** | Dépend de #6 |
| #61 Tests d'accès API sécurisée (rôles) | Johan | **Débloquée** : les endpoints métier qui manquaient (#28, #32, #33, #59, #60) sont livrés |
Quatre de ces six n'ont personne dessus, dont les deux DAG Airflow qui viennent tout juste d'être
débloqués. **Meryem, qui a écrit l'ingestion (#14), n'a plus d'issue assignée** : c'est le
rapprochement le plus évident à faire ce matin.
## 5. À décider ensemble ce matin
1. **`main` est figée au 14/09.** `dev` a **161 commits d'avance** et rien n'a jamais été remonté.
Le rendu du 25/09 se fait sur le Git : il faut décider maintenant qui merge `dev` → `main`,
quand, et si on le fait en continu ou en une fois à la fin. Une remontée de 161 commits la
veille du rendu est le risque le plus concret du projet aujourd'hui.
2. **Affecter les 4 issues J3 orphelines aujourd'hui** : #6 (entraînement du modèle), #15 et #16
(DAG Airflow, débloqués), #44 (tests ML). Proposition : #15/#16 à Meryem (continuité de #14),
#6 à Dorian en suite de #5.
3. **Purger la file de PR : 5 ouvertes, dont une depuis le 16/09.**
| PR | Auteur | CI | Ce qui bloque |
|---|---|---|---|
| #84 `sites/{id}/current` | Johan | verte | **ouverte depuis le 16/09**, rien ne la retient |
| #103 vue détail d'un site | Johan | verte sauf SonarCloud | vérification visuelle |
| #101 couverture tests front | Valentin | **entièrement verte** | personne n'a reviewé |
| #100 audit dépendances CI | Valentin | verte sauf SonarCloud | personne n'a reviewé |
| #99 config SonarQube | Ines | **SonarQube en échec** | approuvée, mais le check rouge |
SonarCloud est en échec sur #103 et #100, SonarQube sur #99. On avait acté hier que
« SonarCloud non-bloquant = go de merge ». **Soit on applique cette règle pour de bon et on
merge, soit Ines finit #99 et on redevient strict.** Rester entre les deux fait que rien ne part.
4. **J5 n'a aucun assigné à 5 jours de l'échéance** : #41 (DAST OWASP ZAP), #45 (tests
d'intégration API↔DB↔ML), #46 (E2E Playwright), #47 (tests de charge k6). Ce sont exactement
les livrables qui servent de preuve à EC03 et EC04. À nommer aujourd'hui, même sans démarrer.
5. **Hygiène de suivi** : #30 est livrée (PR #94 mergée hier) mais son issue est ouverte, et #29
attend juste le merge de #84. Deux issues qui font croire à du reste à faire. À fermer.
6. **J4 : 20 ouvertes pour lundi.** Le front concentre le volume (#9 widget alerte de pic, #10 vue
recommandations, #11 responsive, #54 comparateur de scénarios), plus le ML applicatif (#37
service de scoring, #38 moteur de règles) et l'infra (#22 secrets, #24 MinIO, #26 monitoring,
#36 rétention, #42 chiffrement au repos, #43 accessibilité). Hier il a été acté de **ne rien
couper**. À 3 jours de l'échéance et 5 personnes, ce choix se reconfirme ou se révise ce matin,
avec les chiffres sous les yeux.
## 6. Ce qui va bien, et qu'il faut garder
- Le rythme du 17/09 (10 issues fermées, 8 PR mergées) est le bon rythme. Tenu 5 jours, il vide
la file.
- La chaîne de revue fonctionne : Dorian a reviewé #93 et #95, les remarques ont été traitées
avant merge.
- Le backend est essentiellement là : auth + RBAC + audit, `/sites`, `/readings`, `/stats/summary`,
`/alerts`, `/recommendations`, `/sensors/status`, contrat OpenAPI versionné, import historique.
Le reste du projet s'appuie dessus, et ce socle ne bougera plus.
- 5 ADR écrits et à jour : c'est de la matière directement réutilisable pour EC01 et EC02.
+51 -5
View File
@@ -57,6 +57,46 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign
aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs
lags/moyennes glissantes, une fuite qui masquerait un surapprentissage. lags/moyennes glissantes, une fuite qui masquerait un surapprentissage.
## Scoring
```bash
uv run python -m enervision_ml.score --csv data/all_sites_combined.csv
# ou, une fois la base peuplee et ML_DATABASE_URL positionnee :
uv run python -m enervision_ml.score
```
Calcule, pour chaque site (ou un seul avec `--site-id`), la consommation prevue de l'heure suivant
sa derniere lecture connue, et ecrit une ligne dans `prediction`. Etapes, cf. `ML-START.md`
section 2 :
1. Lit une fenetre recente de `reading`+`site` (21 jours par defaut, une marge au-dessus des 168h
necessaires au lag hebdomadaire) plutot que tout l'historique -- le meme piege que celui deja
corrige sur `GET /readings` (fenetre non plafonnee sur une hypertable).
2. Ajoute une ligne "future" par site (l'heure suivante) et calcule ses features avec
`enervision_ml.features.build_features`, **exactement** la meme fonction qu'a l'entrainement.
3. Si le lag de 168h est absent (moins d'une semaine d'historique pour ce site) : ecrit
`status="insufficient_data"` directement, sans jamais appeler LightGBM.
4. Sinon : appelle `booster.predict(...)` et ecrit `status="available"` avec la valeur predite.
`--model` pointe vers le fichier entraine (`models/lightgbm-consumption.txt` par defaut).
`model_reference` en base est le hache SHA-256 (tronque) du fichier modele, pas son nom de
fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne
distinguerait pas deux versions du modele.
En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future"
calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le
pipeline sans base joignable.
**Limite assumee** : la feature `is_working_hours` de la ligne future est recopiee depuis la
derniere lecture reelle, pas recalculee -- il n'existe aucune regle horaire ouvrable dans ce
depot (elle vit dans le generateur du jeu de donnees d'origine). L'approximation n'est fausse
qu'aux heures de bascule ouverture/fermeture, sur une seule feature parmi une dizaine, pour une
prevision a un seul pas.
`prediction` n'a pas de contrainte d'unicite sur `(site_id, target_at)` : chaque run de scoring
insere une nouvelle ligne plutot que d'ecraser la precedente, pour garder une trace de chaque
prevision (utile plus tard pour comparer prevision et realise, surveillance de derive #44/#45).
## Commandes ## Commandes
```bash ```bash
@@ -81,8 +121,14 @@ environnement de developpement pour le moment.
## Piege a connaitre ## Piege a connaitre
`enervision_ml.features.build_features` est **le seul endroit** qui doit construire les features `enervision_ml.features.build_features` est **le seul endroit** qui doit construire les features
du modele, a l'entrainement comme au futur scoring (service #37, pas encore construit). Si les du modele, a l'entrainement comme au scoring (`enervision_ml.score`). Si les deux divergent meme
deux divergent meme legerement (une fenetre de moyenne glissante calculee differemment, par legerement (une fenetre de moyenne glissante calculee differemment, par exemple), le modele
exemple), le modele recoit en production des features qui ne ressemblent plus a ce qu'il a recoit en production des features qui ne ressemblent plus a ce qu'il a appris, et ses predictions
appris, et ses predictions deviennent silencieusement mauvaises sans qu'aucune erreur ne se deviennent silencieusement mauvaises sans qu'aucune erreur ne se declenche. Ne jamais reecrire
declenche. Ne jamais reecrire cette logique ailleurs : importer `enervision_ml.features`. cette logique ailleurs : importer `enervision_ml.features`.
## Et cote API ?
`GET /api/v1/predictions` (backend, `apps/backend`) lit ce que `enervision_ml.score` a ecrit dans
`prediction` -- la derniere prevision par site, jamais un recalcul a la volee. FastAPI ne fait
jamais tourner LightGBM lui-meme, cf. `ML-START.md` section 3.
+66 -3
View File
@@ -16,6 +16,7 @@ Deux chemins, qui doivent produire le meme schema de sortie (colonnes `site_id`,
colonne est renvoyee a `NaN`, que LightGBM gere nativement comme valeur manquante. colonne est renvoyee a `NaN`, que LightGBM gere nativement comme valeur manquante.
""" """
from datetime import datetime
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
@@ -34,6 +35,14 @@ OUTPUT_COLUMNS = [
"capacity_kw", "capacity_kw",
] ]
NUMERIC_COLUMNS = [
"consumption_kwh",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"capacity_kw",
]
_READING_QUERY = text( _READING_QUERY = text(
""" """
SELECT SELECT
@@ -53,10 +62,43 @@ _READING_QUERY = text(
) )
_RECENT_READING_QUERY = text(
"""
SELECT
r.site_id,
r.timestamp,
r.consumption_kwh,
r.temperature_celsius,
r.humidity_percent,
r.solar_irradiance_wm2,
r.is_working_hours,
s.site_type,
s.capacity_kw
FROM reading r
JOIN site s ON s.site_id = r.site_id
WHERE r.timestamp >= :since
ORDER BY r.site_id, r.timestamp
"""
)
def load_from_database(connection: Connectable) -> pd.DataFrame: def load_from_database(connection: Connectable) -> pd.DataFrame:
"""Lit l'historique complet `reading` + `site` depuis PostgreSQL.""" """Lit l'historique complet `reading` + `site` depuis PostgreSQL. Entrainement seulement :
le scoring n'a besoin que d'une fenetre recente, cf. `load_recent_from_database`.
"""
frame = pd.read_sql(_READING_QUERY, connection) frame = pd.read_sql(_READING_QUERY, connection)
return frame[OUTPUT_COLUMNS] return _typer(frame[OUTPUT_COLUMNS])
def load_recent_from_database(connection: Connectable, *, since: datetime) -> pd.DataFrame:
"""Lit `reading` + `site` depuis `since` seulement, pour le scoring.
Piege evite : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le prochain
pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non
plafonnee sur une table pouvant porter des annees d'historique).
"""
frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since})
return _typer(frame[OUTPUT_COLUMNS])
def load_from_csv(csv_path: Path) -> pd.DataFrame: def load_from_csv(csv_path: Path) -> pd.DataFrame:
@@ -65,4 +107,25 @@ def load_from_csv(csv_path: Path) -> pd.DataFrame:
frame["capacity_kw"] = float("nan") frame["capacity_kw"] = float("nan")
frame["is_working_hours"] = frame["is_working_hours"].astype(bool) frame["is_working_hours"] = frame["is_working_hours"].astype(bool)
return frame[OUTPUT_COLUMNS] return _typer(frame[OUTPUT_COLUMNS])
def _typer(frame: pd.DataFrame) -> pd.DataFrame:
"""Force le typage numerique attendu par LightGBM.
Piege reel, pas theorique : `site.capacity_kw` n'est peuple par aucun pipeline d'ingestion
aujourd'hui (`historical_import.py` ne pose que `site_type`/`site_name`). Une colonne
entierement `NULL` revient de `pd.read_sql` en dtype `object` plutot que `float64`, ce que
LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi
n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
seulement `capacity_kw`.
Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas
seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement
`NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au
scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees.
"""
typee = frame.copy()
for colonne in NUMERIC_COLUMNS:
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce")
return typee
+318
View File
@@ -0,0 +1,318 @@
"""Scoring du modele LightGBM : calcule et enregistre la consommation prevue du prochain pas
horaire, par site.
CLI autonome, sur le meme gabarit que `enervision_ml.train` et
`apps/backend/app/etl/historical_import.py`. Cf. `docs/ML-START.md`, section 2.
uv run python -m enervision_ml.score --csv ../ml/data/all_sites_combined.csv
uv run python -m enervision_ml.score # lit ML_DATABASE_URL, ecrit dans `prediction`
Reutilise `enervision_ml.features.build_features` tel quel (jamais reecrit) : c'est la garantie
contre le train/serve skew documentee dans ce module.
"""
import argparse
import hashlib
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, cast
import lightgbm as lgb
import pandas as pd
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Connection
from enervision_ml import config
from enervision_ml.data import load_from_csv, load_recent_from_database
from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_features, feature_columns
# Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure.
LOOKBACK = timedelta(days=21)
# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure
# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont
# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait
# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard)
# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours.
MAX_STALENESS = timedelta(hours=24)
TARGET_METRIC = "consumption_kwh"
PERIOD_MINUTES = 60
LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h"
INSUFFICIENT_DATA_REASON = (
"Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site."
)
def _stale_reason(age: pd.Timedelta) -> str:
return (
f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h "
f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement "
"arrêtée pour ce site."
)
@dataclass(frozen=True, slots=True)
class ScoredSite:
site_id: str
target_at: datetime
status: str
predicted_value: float | None
failure_reason: str | None
def model_reference(model_path: Path) -> str:
"""Identifiant stable du modele utilise, insensible au fait que `train.py` reecrive
toujours le meme nom de fichier a chaque entrainement (pas de versioning par nom, cf.
`ml/README.md`)."""
empreinte = hashlib.sha256(model_path.read_bytes()).hexdigest()
return f"lightgbm-{empreinte[:12]}"
def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> pd.DataFrame:
"""Ajoute une ligne future (l'heure suivant la derniere lecture connue) par site, et calcule
ses features par `build_features` -- exactement comme a l'entrainement, seule la cible de
cette ligne est inconnue.
Piege assume : `is_working_hours` de la ligne future est copie de la derniere lecture reelle,
pas recalcule. Il n'existe aucune regle horaire ouvrable dans ce depot (elle vit dans le
generateur du jeu de donnees d'origine, hors de ce code) ; l'approximation n'est fausse
qu'aux heures de bascule (ouverture/fermeture), sur une seule feature parmi une dizaine, pour
une prevision a un pas seulement.
"""
travail = recent if site_id is None else recent[recent["site_id"] == site_id]
if travail.empty:
return build_features(travail)
dernieres = (
travail.sort_values("timestamp").groupby("site_id", as_index=False, sort=False).tail(1)
).copy()
dernieres["timestamp"] = dernieres["timestamp"] + pd.Timedelta(hours=1)
dernieres[TARGET_COLUMN] = float("nan")
# Meteo future inconnue (cf. piege documente dans `enervision_ml.features.build_features`) :
# laisser `NaN` ici n'a aucun effet sur les features utilisees, qui ne prennent la meteo que
# decalee.
for colonne in WEATHER_COLUMNS:
dernieres[colonne] = float("nan")
etendu = pd.concat([travail, dernieres], ignore_index=True)
features = build_features(etendu)
return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True)
def score(
booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime
) -> list[ScoredSite]:
resultats: list[ScoredSite] = []
# `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) :
# on en deduit l'age de cette derniere lecture par rapport a `instant`.
travail = scoring_frame.copy()
travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1))
perimes = travail[travail["_age"] > MAX_STALENESS]
for enregistrement in _records(perimes):
resultats.append(
ScoredSite(
site_id=enregistrement["site_id"],
target_at=enregistrement["timestamp"].to_pydatetime(),
status="insufficient_data",
predicted_value=None,
failure_reason=_stale_reason(enregistrement["_age"]),
)
)
a_jour = travail[travail["_age"] <= MAX_STALENESS]
insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()]
for enregistrement in _records(insuffisants):
resultats.append(
ScoredSite(
site_id=enregistrement["site_id"],
target_at=enregistrement["timestamp"].to_pydatetime(),
status="insufficient_data",
predicted_value=None,
failure_reason=INSUFFICIENT_DATA_REASON,
)
)
suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()]
if not suffisants.empty:
typee = suffisants.copy()
typee["site_type"] = typee["site_type"].astype("category")
predictions = booster.predict(typee[feature_columns()])
for enregistrement, valeur in zip(_records(suffisants), predictions, strict=True):
resultats.append(
ScoredSite(
site_id=enregistrement["site_id"],
target_at=enregistrement["timestamp"].to_pydatetime(),
status="available",
predicted_value=float(valeur),
failure_reason=None,
)
)
return resultats
def _records(frame: pd.DataFrame) -> list[dict[str, Any]]:
return cast(list[dict[str, Any]], frame.to_dict(orient="records"))
_INSERT_PREDICTION = text(
"""
INSERT INTO prediction (
site_id, target_at, target_metric, period_minutes,
predicted_value, model_reference, status, failure_reason
) VALUES (
:site_id, :target_at, :target_metric, :period_minutes,
:predicted_value, :model_reference, :status, :failure_reason
)
"""
)
def write_predictions(
connection: Connection, resultats: list[ScoredSite], *, reference: str
) -> None:
"""Ecrit une ligne par site score. Insertion seule, jamais de mise a jour : `prediction`
n'a pas de contrainte d'unicite sur `(site_id, target_at)`, chaque run garde sa propre trace
plutot que d'ecraser la precedente -- utile plus tard pour comparer prevision et realise
(surveillance de derive, #44/#45)."""
if not resultats:
return
lignes = [
{
"site_id": r.site_id,
"target_at": r.target_at,
"target_metric": TARGET_METRIC,
"period_minutes": PERIOD_MINUTES,
"predicted_value": r.predicted_value,
"model_reference": reference,
"status": r.status,
"failure_reason": r.failure_reason,
}
for r in resultats
]
connection.execute(_INSERT_PREDICTION, lignes)
def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]:
brute = load_from_csv(csv_path)
instant = now or (
brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC)
)
return brute[brute["timestamp"] >= instant - LOOKBACK], instant
def _score_frame(
recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime
) -> list[ScoredSite]:
scoring_frame = build_scoring_frame(recent, site_id=site_id)
if scoring_frame.empty:
return []
booster = lgb.Booster(model_file=str(model_path))
return score(booster, scoring_frame, instant=instant)
def run_scoring(
*,
model_path: Path,
csv_path: Path | None = None,
site_id: str | None = None,
now: datetime | None = None,
) -> list[ScoredSite]:
"""Score le prochain pas horaire par site et l'ecrit dans `prediction`.
En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future"
calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base
joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans
`build_scoring_frame` : le filtrer aussi ici serait redondant.
"""
if csv_path is not None:
recent, instant = _load_recent_from_csv(csv_path, now=now)
return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
# Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape.
engine = create_engine(config.database_url())
try:
instant = now or datetime.now(UTC)
recent = load_recent_from_database(engine, since=instant - LOOKBACK)
resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
reference = model_reference(model_path)
with engine.begin() as connection:
write_predictions(connection, resultats, reference=reference)
return resultats
finally:
engine.dispose()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Scoring du modele LightGBM EnerVision")
parser.add_argument(
"--model",
type=Path,
default=Path("models/lightgbm-consumption.txt"),
help="Chemin du modele entraine. Defaut : models/lightgbm-consumption.txt.",
)
parser.add_argument(
"--csv",
type=Path,
default=None,
help=(
"Instantane historique de demarrage/demo, rien n'est ecrit en base. Omis, lit "
"ML_DATABASE_URL, se connecte a PostgreSQL et ecrit dans `prediction`."
),
)
parser.add_argument(
"--site-id",
default=None,
help="Ne score que ce site. Omis, tous les sites presents dans la fenetre recente.",
)
parser.add_argument(
"--now",
type=_parse_instant,
default=None,
help=(
"Instant de reference (ISO 8601), pour tester ou demontrer le scoring cote base sur "
"des donnees anciennes (ex. le jeu de donnees historique, qui s'arrete fin 2024). "
"Omis, horloge systeme reelle."
),
)
return parser.parse_args()
def _parse_instant(valeur: str) -> datetime:
instant = datetime.fromisoformat(valeur)
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
def main() -> None:
args = parse_args()
resultats = run_scoring(
model_path=args.model, csv_path=args.csv, site_id=args.site_id, now=args.now
)
if not resultats:
print("Aucun site a scorer (aucune lecture recente dans la fenetre).")
return
for r in resultats:
if r.status == "available":
print(f"{r.site_id} @ {r.target_at} : {r.predicted_value:.2f} kWh")
else:
print(f"{r.site_id} @ {r.target_at} : {r.status} ({r.failure_reason})")
if args.csv is not None:
print("\nMode --csv : instantane historique, rien ecrit en base.")
if __name__ == "__main__":
main()
+3 -3
View File
@@ -47,9 +47,9 @@ select = [
"S", "S",
"PT", "PT",
] ]
# N806 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable mal # N806/N803 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable
# nommee. # ou un argument mal nomme.
ignore = ["B008", "N806"] ignore = ["B008", "N806", "N803"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"] "tests/**/*.py" = ["S101"]
+55
View File
@@ -0,0 +1,55 @@
from pathlib import Path
import pandas as pd
from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv
_CSV_HEADER = (
"site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent,"
"solar_irradiance_wm2,is_working_hours,site_type"
)
def write_csv(tmp_path: Path, *lignes: str) -> Path:
csv_path = tmp_path / "recent.csv"
csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n")
return csv_path
def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None:
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
frame = load_from_csv(csv_path)
for colonne in NUMERIC_COLUMNS:
assert frame[colonne].dtype == "float64"
def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None:
# Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type
# alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de
# divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement
# `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui).
csv_path = write_csv(
tmp_path,
"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office",
"SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office",
)
frame = load_from_csv(csv_path)
assert frame["consumption_kwh"].dtype == "float64"
assert frame["consumption_kwh"].iloc[0] == 10.5
assert pd.isna(frame["consumption_kwh"].iloc[1])
def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None:
# `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette
# affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test
# garde le contrat visible malgre tout, au cas ou l'implementation changerait.
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
frame = load_from_csv(csv_path)
assert frame["capacity_kw"].dtype == "float64"
assert pd.isna(frame["capacity_kw"].iloc[0])
+277
View File
@@ -0,0 +1,277 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pandas as pd
import pytest
from enervision_ml.features import TARGET_COLUMN
from enervision_ml.score import (
LAG_168H_COLUMN,
MAX_STALENESS,
ScoredSite,
build_scoring_frame,
model_reference,
run_scoring,
score,
write_predictions,
)
def make_recent(
site_id: str, *, heures: int, depart: datetime, valeur: float = 10.0
) -> pd.DataFrame:
instants = [depart + timedelta(hours=h) for h in range(heures)]
return pd.DataFrame(
{
"site_id": site_id,
"timestamp": instants,
TARGET_COLUMN: [valeur + h for h in range(heures)],
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": True,
"site_type": "office",
"capacity_kw": 100.0,
}
)
class FakeBooster:
def __init__(self, valeur: float = 42.0) -> None:
self.valeur = valeur
self.appels: list[int] = []
def predict(self, X: Any) -> list[float]:
self.appels.append(len(X))
return [self.valeur] * len(X)
class FakeConnection:
def __init__(self) -> None:
self.appels: list[tuple[Any, Any]] = []
def execute(self, statement: Any, parameters: Any = None) -> None:
self.appels.append((statement, parameters))
def test_build_scoring_frame_adds_one_row_per_site_one_hour_after_the_last_reading() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = pd.concat(
[
make_recent("site-a", heures=200, depart=depart),
make_recent("site-b", heures=200, depart=depart),
],
ignore_index=True,
)
scoring_frame = build_scoring_frame(recent)
assert set(scoring_frame["site_id"]) == {"site-a", "site-b"}
derniere_lecture = depart + timedelta(hours=199)
assert (scoring_frame["timestamp"] == derniere_lecture + timedelta(hours=1)).all()
def test_build_scoring_frame_computes_lags_from_real_history() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = make_recent("site-a", heures=200, depart=depart)
scoring_frame = build_scoring_frame(recent)
ligne = scoring_frame.iloc[0]
# La cible future n'existe pas : le lag d'1h doit valoir la toute derniere valeur reelle.
assert ligne[f"{TARGET_COLUMN}_lag_1h"] == recent[TARGET_COLUMN].iloc[-1]
def test_build_scoring_frame_flags_insufficient_history_under_168_hours() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = make_recent("site-a", heures=100, depart=depart)
scoring_frame = build_scoring_frame(recent)
assert pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN])
def test_build_scoring_frame_accepts_a_full_week_of_history() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = make_recent("site-a", heures=169, depart=depart)
scoring_frame = build_scoring_frame(recent)
assert not pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN])
def test_build_scoring_frame_filters_to_a_single_site() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
recent = pd.concat(
[
make_recent("site-a", heures=200, depart=depart),
make_recent("site-b", heures=200, depart=depart),
],
ignore_index=True,
)
scoring_frame = build_scoring_frame(recent, site_id="site-a")
assert scoring_frame["site_id"].tolist() == ["site-a"]
def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> None:
recent = make_recent("site-a", heures=0, depart=datetime(2026, 1, 1, tzinfo=UTC))
scoring_frame = build_scoring_frame(recent)
assert scoring_frame.empty
def target_at_for(depart: datetime, heures: int) -> datetime:
"""`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture
+ 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption,
pour les tests qui ne visent pas ce filtre."""
return depart + timedelta(hours=heures)
def test_score_marks_insufficient_history_without_calling_the_model() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart))
booster = FakeBooster()
resultats = score(
booster, # type: ignore[arg-type]
scoring_frame,
instant=target_at_for(depart, 100),
)
assert resultats == [
ScoredSite(
site_id="site-a",
target_at=resultats[0].target_at,
status="insufficient_data",
predicted_value=None,
failure_reason=resultats[0].failure_reason,
)
]
assert booster.appels == []
def test_score_predicts_when_history_is_sufficient() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
booster = FakeBooster(valeur=99.5)
resultats = score(
booster, # type: ignore[arg-type]
scoring_frame,
instant=target_at_for(depart, 200),
)
assert len(resultats) == 1
assert resultats[0].status == "available"
assert resultats[0].predicted_value == 99.5
assert resultats[0].failure_reason is None
assert booster.appels == [1]
def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
# Historique largement suffisant (168h+), mais l'instant de reference est loin apres la
# derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique.
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1)
booster = FakeBooster()
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
assert len(resultats) == 1
assert resultats[0].status == "insufficient_data"
assert resultats[0].predicted_value is None
assert "vieille" in (resultats[0].failure_reason or "")
assert booster.appels == []
def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
# `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure
# pour retomber exactement sur le seuil, ni en dessous ni au dessus.
instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1)
booster = FakeBooster(valeur=12.0)
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
assert resultats[0].status == "available"
assert booster.appels == [1]
def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None:
connection = FakeConnection()
write_predictions(connection, [], reference="lightgbm-test") # type: ignore[arg-type]
assert connection.appels == []
def test_write_predictions_sends_one_row_per_result() -> None:
connection = FakeConnection()
resultats = [
ScoredSite("site-a", datetime(2026, 1, 1, tzinfo=UTC), "available", 42.0, None),
ScoredSite(
"site-b",
datetime(2026, 1, 1, tzinfo=UTC),
"insufficient_data",
None,
"pas assez d'historique",
),
]
write_predictions(connection, resultats, reference="lightgbm-test") # type: ignore[arg-type]
assert len(connection.appels) == 1
_, lignes = connection.appels[0]
assert len(lignes) == 2
assert lignes[0]["model_reference"] == "lightgbm-test"
assert lignes[0]["target_metric"] == "consumption_kwh"
assert lignes[0]["period_minutes"] == 60
def test_model_reference_is_stable_for_the_same_file_content(tmp_path: Path) -> None:
model_path = tmp_path / "model.txt"
model_path.write_bytes(b"contenu-du-modele")
assert model_reference(model_path) == model_reference(model_path)
def test_model_reference_changes_with_the_file_content(tmp_path: Path) -> None:
premier = tmp_path / "model-a.txt"
premier.write_bytes(b"version-1")
second = tmp_path / "model-b.txt"
second.write_bytes(b"version-2")
assert model_reference(premier) != model_reference(second)
def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Path) -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
frame = pd.concat(
[
make_recent("site-a", heures=400, depart=depart),
make_recent("site-b", heures=400, depart=depart),
],
ignore_index=True,
)
csv_path = tmp_path / "recent.csv"
frame.to_csv(csv_path, index=False)
model_path = tmp_path / "model.txt"
model_path.write_bytes(b"peu importe le contenu pour ce test")
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
"enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0)
)
resultats = run_scoring(model_path=model_path, csv_path=csv_path)
assert {r.site_id for r in resultats} == {"site-a", "site-b"}
assert all(r.status == "available" for r in resultats)
assert all(r.predicted_value == 7.0 for r in resultats)
+12 -9
View File
@@ -1,14 +1,17 @@
sonar.projectKey=ProjetPiscine_EnerVision sonar.projectKey=ProjetPiscine_EnerVision
sonar.organization=groupe3-ener-vision sonar.organization=groupe3-ener-vision
sonar.sourceEncoding=UTF-8
# Dossier contenant le code source
sonar.sources=apps/frontend/src,apps/backend
# Dossier contenant les tests
sonar.tests=apps/frontend/src,apps/backend/tests
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
# This is the name and version displayed in the SonarCloud UI. # Liste des fichiers et dossiers à exclure de l'analyse
#sonar.projectName=ProjetPiscine_EnerVision sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
#sonar.projectVersion=1.0
# Chemin vers le rapport de couverture de code
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. # Fichier généré par Pytest
#sonar.sources=. sonar.python.coverage.reportPaths=apps/backend/coverage.xml
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8