Compare commits

...
Author SHA1 Message Date
Johan LEROY af2b8cb768 ci(sonar): déclenche l'analyse quand sonar-project.properties change
SonarQube / build-back (push) Successful in 1m9s
SonarQube / build-front (push) Successful in 9m35s
SonarQube / test-back (push) Failing after 59s
SonarQube / test-front (push) Failing after 5m2s
SonarQube / SonarQube (push) Skipped
Le fichier pilote le périmètre, la classification des tests et les rapports de
couverture, mais il ne figurait pas dans les paths du workflow : un commit qui
ne touchait que lui laissait le quality gate figé sur son verdict précédent.
2026-09-18 14:28:50 +02:00
Johan LEROY 9e6a5c0663 ci(sonar): classe tout apps/backend/tests comme test, pas les seuls test_*.py
Le quality gate de la PR tombait à 0 % de couverture sur le nouveau code :
tests/api/acces.py ne matche ni `**/*test_*.py` ni `**/*test.py`, donc Sonar
le comptait en code de production, qu'aucun rapport de couverture ne mesure.
Le pattern `tests` des exclusions ne l'attrapait pas non plus : sans wildcard,
il ne désigne que le chemin `tests` à la racine du dépôt.

Même défaut pour tests/factories.py et les __init__.py, déjà sur dev.
2026-09-18 14:28:05 +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
ineszang44 12fb8860d1 chore: fichier de configuration pour chaque module (front et back) 2026-09-18 11:54:21 +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
Johan LEROY 7db57f162c refactor(frontend): aligne la vue détail sur le contrat SiteCurrentResponse
`/sites/{site_id}/current` renvoie un objet toujours présent, sans `reading_id`
ni `source`, avec `timestamp` nullable et `null_reasons` non nullable. La vue
s'appuyait sur une réponse nulle pour détecter l'absence de mesure : elle
s'appuie désormais sur `timestamp`, et affiche `data_quality`, que le contrat
précédent ne portait pas.

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

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

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

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

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

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

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

Closes #49
2026-09-17 15:58:35 +02:00
Johan LEROYandGitHub 1d8c986386 Merge pull request #93 from ineszang/feat/design-system
feat(frontend): design système - tokens, composants ui et restylage des pages
2026-09-17 15:56:24 +02:00
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
56 changed files with 5990 additions and 153 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
+26 -39
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:
@@ -36,13 +23,25 @@ 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 - run: npm ci
working-directory: apps/frontend working-directory: apps/frontend
- run: npm run build - run: npm run build
working-directory: apps/frontend working-directory: apps/frontend
test: security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend
test:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -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"
+134
View File
@@ -0,0 +1,134 @@
name: SonarQube
on:
push:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- ".github/workflows/sonarqube.yml"
- "sonar-project.properties"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- ".github/workflows/sonarqube.yml"
- "sonar-project.properties"
# 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 }}
+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
+1 -1
View File
@@ -171,7 +171,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)]
+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)
+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,
*, *,
+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")
+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
+221
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": [
@@ -2297,6 +2384,140 @@
], ],
"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"
},
"SiteResponse": { "SiteResponse": {
"properties": { "properties": {
"site_id": { "site_id": {
+86
View File
@@ -0,0 +1,86 @@
# 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/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")
+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:
@@ -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:
+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
+11
View File
@@ -12,4 +12,15 @@ export const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
}, },
{
path: 'sites',
canActivate: [authGuard],
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
},
{
path: 'sites/:siteId',
canActivate: [authGuard],
loadComponent: () =>
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
},
]; ];
@@ -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,87 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { SitesService } from './sites.service';
import { environment } from '../../../environments/environment';
describe('SitesService', () => {
let service: SitesService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(SitesService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('appelle le bon endpoint et retourne la liste des sites', () => {
let result: unknown;
service.getSites().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites`);
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 }[])[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',
]);
});
});
@@ -0,0 +1,22 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Site } from '../../shared/models/site.model';
import { SiteCurrent } from '../../shared/models/site-current.model';
@Service()
export class SitesService {
private http = inject(HttpClient);
getSites() {
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
}
getSite(siteId: string) {
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
}
getCurrent(siteId: string) {
return this.http.get<SiteCurrent>(`${environment.apiUrl}/sites/${siteId}/current`);
}
}
@@ -1,15 +1,24 @@
<div class="dashboard"> <div class="dashboard">
<header class="dashboard__header"> <header class="dashboard__header">
<div class="dashboard__brand"> <div class="dashboard__brand">
<ev-brand class="dashboard__logo" /> <a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="dashboard__logo" />
</a>
<div> <div>
<h1>Vue d'ensemble</h1> <h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p> <p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div> </div>
</div> </div>
<ev-button class="logout-button" variant="secondary" [fullWidth]="false" (click)="onLogout()" <div class="dashboard__actions">
>Déconnexion</ev-button <a routerLink="/sites" class="ev-link">Voir les sites</a>
> <ev-button
class="logout-button"
variant="secondary"
[fullWidth]="false"
(click)="onLogout()"
>Déconnexion</ev-button
>
</div>
</header> </header>
@if (error(); as message) { @if (error(); as message) {
@@ -34,6 +34,12 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.dashboard__actions {
display: flex;
align-items: center;
gap: 1rem;
}
h2 { h2 {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
@@ -5,7 +5,7 @@ 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 {AuthService} from '../../core/services/auth.service'; import {AuthService} from '../../core/services/auth.service';
import {Router} from '@angular/router'; import {Router, provideRouter} from '@angular/router';
vi.mock('chart.js', () => { vi.mock('chart.js', () => {
class ChartMock { class ChartMock {
@@ -29,6 +29,7 @@ describe('Dashboard', () => {
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
provideRouter([]),
], ],
}); });
@@ -60,6 +61,7 @@ describe('Dashboard', () => {
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
provideRouter([]),
], ],
}); });
@@ -86,6 +88,7 @@ describe('Dashboard', () => {
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
provideRouter([]),
], ],
}); });
@@ -99,7 +102,6 @@ describe('Dashboard', () => {
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() };
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [Dashboard], imports: [Dashboard],
@@ -107,18 +109,21 @@ describe('Dashboard', () => {
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock }, { provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock }, provideRouter([]),
], ],
}); });
const fixture = TestBed.createComponent(Dashboard); const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button'); const button = fixture.nativeElement.querySelector('.logout-button');
button.click(); button.click();
expect(authMock.logout).toHaveBeenCalled(); expect(authMock.logout).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);
}); });
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
@@ -127,26 +132,27 @@ describe('Dashboard', () => {
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(),
}; };
const routerMock = { navigate: vi.fn() };
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: AuthService, useValue: authMock }, { provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock }, provideRouter([]),
], ],
}); });
const fixture = TestBed.createComponent(Dashboard); const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button'); const button = fixture.nativeElement.querySelector('.logout-button');
button.click(); button.click();
expect(authMock.clearSession).toHaveBeenCalled(); expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);
}); });
it('distingue le ton des sévérités high et critical', () => { it('distingue le ton des sévérités high et critical', () => {
@@ -158,6 +164,7 @@ describe('Dashboard', () => {
providers: [ providers: [
{ provide: StatsService, useValue: statsMock }, { provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock }, { provide: AlertsService, useValue: alertsMock },
provideRouter([]),
], ],
}); });
@@ -2,7 +2,7 @@ import { Component, OnInit, inject, signal, DestroyRef } 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 } from '@angular/common';
import { Router } 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';
@@ -30,7 +30,17 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge, Brand, Button], imports: [
DecimalPipe,
RouterLink,
ConsumptionGauge,
SiteLoadChart,
Card,
EvAlert,
Badge,
Brand,
Button,
],
templateUrl: './dashboard.html', templateUrl: './dashboard.html',
styleUrl: './dashboard.scss', styleUrl: './dashboard.scss',
}) })
@@ -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,46 @@
<div class="site-list">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="site-list__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-list__logo" />
</a>
<div>
<h1>Sites</h1>
<p class="site-list__subtitle">Vue d'ensemble du parc suivi</p>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
<ev-card class="table-card">
<table class="sites-table">
<thead>
<tr>
<th>Nom</th>
<th>Type</th>
<th>Localisation</th>
<th>Capacité (kW)</th>
<th>Statut</th>
<th></th>
</tr>
</thead>
<tbody>
@for (site of sites(); track site.site_id) {
<tr>
<td>{{ site.site_name }}</td>
<td>{{ site.site_type }}</td>
<td>{{ site.location || '-' }}</td>
<td>{{ site.capacity_kw ?? '-' }}</td>
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr>
}
</tbody>
</table>
</ev-card>
</div>
@@ -0,0 +1,63 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.site-list__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.site-list__logo {
font-size: 1.3rem;
}
.site-list__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
}
.table-card {
padding: 0;
overflow: hidden;
}
.sites-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
}
@@ -0,0 +1,81 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { of, throwError } from 'rxjs';
import { SiteList } from './site-list';
import { SitesService } from '../../../core/services/sites.service';
describe('SiteList', () => {
it('charge et affiche les sites au démarrage', () => {
const sitesMock = {
getSites: vi.fn().mockReturnValue(
of([
{
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
]),
),
};
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
expect(sitesMock.getSites).toHaveBeenCalled();
expect(fixture.componentInstance.sites().length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
});
it("signale l'indisponibilité quand le chargement échoue", () => {
const sitesMock = { getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.sites().length).toBe(0);
});
it('affiche un tiret pour les champs nullables', () => {
const sitesMock = {
getSites: vi.fn().mockReturnValue(
of([
{
site_id: 'SITE002',
site_name: 'Site 2',
site_type: 'bureau',
location: null,
capacity_kw: null,
status: null,
},
]),
),
};
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
const cells = fixture.nativeElement.querySelectorAll('td');
expect(cells[2].textContent.trim()).toBe('-');
expect(cells[3].textContent.trim()).toBe('-');
});
});
@@ -0,0 +1,47 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, EMPTY, Observable } from 'rxjs';
import { SitesService } from '../../../core/services/sites.service';
import { Site } from '../../../shared/models/site.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';
const UNAVAILABLE_MESSAGE = 'Liste des sites indisponible, réessayez plus tard.';
const TON_PAR_STATUT: Record<string, BadgeTone> = {
actif: 'success',
maintenance: 'warning',
hors_service: 'danger',
};
@Component({
selector: 'app-site-list',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand],
templateUrl: './site-list.html',
styleUrl: './site-list.scss',
})
export class SiteList implements OnInit {
private sitesService = inject(SitesService);
sites = signal<Site[]>([]);
error = signal<string | null>(null);
ngOnInit(): void {
this.sitesService
.getSites()
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((sites) => this.sites.set(sites));
}
badgeToneForStatus(status: string | null): BadgeTone {
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
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,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,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;
}
@@ -0,0 +1,8 @@
export interface Site {
site_id: string;
site_name: string;
site_type: string;
location: string | null;
capacity_kw: number | null;
status: string | null;
}
@@ -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,
}; };
+1
View File
@@ -1,6 +1,7 @@
@use 'styles/tokens'; @use 'styles/tokens';
@use 'styles/forms'; @use 'styles/forms';
@use 'styles/auth-page'; @use 'styles/auth-page';
@use 'styles/links';
body { body {
margin: 0; margin: 0;
+34
View File
@@ -0,0 +1,34 @@
.ev-link {
color: var(--color-primary);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.ev-breadcrumb {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted);
margin-bottom: 1.25rem;
a {
color: var(--color-text-muted);
text-decoration: none;
&:hover {
color: var(--color-primary);
text-decoration: underline;
}
}
}
.ev-brand-link {
display: inline-flex;
color: inherit;
text-decoration: none;
}
+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/**',
],
},
},
})
+26 -9
View File
@@ -142,6 +142,7 @@ 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 |
@@ -154,10 +155,12 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
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`, `prediction`) : les quatre couches
@@ -171,7 +174,11 @@ 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 /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,
@@ -268,11 +275,16 @@ Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendat
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 +335,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.
@@ -36,6 +36,13 @@ le fond dégradé et le centrage commun aux pages d'authentification (`login`, `
et à terme `forgot-password`/`reset-password`) : elle enveloppe la carte, pas de duplication du et à terme `forgot-password`/`reset-password`) : elle enveloppe la carte, pas de duplication du
fond par page. fond par page.
Les classes de navigation partagées (`.ev-link`, `.ev-breadcrumb`, `.ev-brand-link`) sont dans
`apps/frontend/src/styles/_links.scss`, importées globalement. Convention pour toute page de la
zone authentifiée (derrière `authGuard`) : le logo (`<ev-brand>`) est enveloppé dans
`<a routerLink="/dashboard" class="ev-brand-link">` pour ramener au tableau de bord en un clic,
et un `<nav class="ev-breadcrumb">` liste le chemin de retour vers les pages parentes quand la
page n'est pas à la racine (voir `site-list`/`site-detail-placeholder` pour l'exemple).
## Composants partagés ## Composants partagés
Dans `apps/frontend/src/app/shared/components/ui/`, chacun standalone, à importer directement Dans `apps/frontend/src/app/shared/components/ui/`, chacun standalone, à importer directement
@@ -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.
+15 -9
View File
@@ -1,14 +1,20 @@
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
# Piège : tout apps/backend/tests/** est du test, pas seulement les fichiers test_*.py.
# Les modules de données (acces.py, factories.py) et les __init__.py comptaient sinon
# comme code de production non couvert, et tiraient la couverture du nouveau code à 0 %.
sonar.tests=apps/frontend/src,apps/backend/tests
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,apps/backend/tests/**/*.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,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,apps/backend/tests/**/*.py
#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