Compare commits

...
Author SHA1 Message Date
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
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
52 changed files with 5477 additions and 98 deletions
+24
View File
@@ -56,3 +56,27 @@ jobs:
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
- name: Tests et couverture
run: uv run pytest --cov-fail-under=85
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
# Pipeline à choix multiple
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:
paths:
- "apps/frontend/**"
@@ -23,8 +9,9 @@ on:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
# Ordre de lancement des jobs
# build -> test -> sonarqube -> deploy
permissions:
contents: read
jobs:
build:
@@ -36,13 +23,25 @@ jobs:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test:
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend
test:
needs: build
runs-on: ubuntu-latest
steps:
@@ -52,26 +51,14 @@ jobs:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- run: npm test -- --watch=false
- 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
sonarqube:
needs: [build, test]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: SonarQube Scan
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"
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
+132
View File
@@ -0,0 +1,132 @@
name: SonarQube
on:
push:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- ".github/workflows/sonarqube.yml"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- ".github/workflows/sonarqube.yml"
# Build l'ensemble du projet, puis lance les tests
# Génère les rapports de couverture, puis lance l'analyse SonarQube
jobs:
build-front:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test-front:
needs: build-front
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
build-back:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: apps/backend
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: apps/backend
- name: Vérifie le formatage
run: uv run ruff format --check .
working-directory: apps/backend
- name: Analyse statique
run: uv run ruff check --output-format=github .
working-directory: apps/backend
- name: Typage
run: uv run mypy app
working-directory: apps/backend
test-back:
needs: build-back
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name : Lancement des tests et génénration du rapport de couverture (Back)
run: uv run pytest --cov-fail-under=85 --cov-report=xml
working-directory: apps/backend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: apps/backend/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4
with:
name: backend-coverage
path: apps/backend
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+1 -1
View File
@@ -171,7 +171,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)]
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)]
+17 -1
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, SiteServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.site import SiteResponse
from app.schemas.site import SiteCurrentResponse, SiteResponse
from app.services.site import SiteNotFoundError
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"
) from erreur
return SiteResponse.model_validate(site)
@router.get(
"/{site_id}/current",
response_model=SiteCurrentResponse,
summary="Dernière mesure d'un site",
responses=REPONSES_INTROUVABLE,
)
async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse:
try:
actuel = await service.current(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteCurrentResponse.model_validate(actuel)
+15 -2
View File
@@ -13,14 +13,27 @@ class ReadingRepository:
async def latest_by_site(self) -> Sequence[Reading]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit.
# ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage
# les égalités de timestamp, que `uq_reading_source` autorise à `source` différente.
requete = (
select(Reading)
.distinct(Reading.site_id)
.order_by(Reading.site_id, Reading.timestamp.desc())
.order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc())
)
return (await self._session.execute(requete)).scalars().all()
async def latest_for_site(self, site_id: str) -> Reading | None:
# Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la
# `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard.
requete = (
select(Reading)
.where(Reading.site_id == site_id)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(1)
)
lecture: Reading | None = await self._session.scalar(requete)
return lecture
async def list_history(
self,
*,
+20
View File
@@ -1,3 +1,6 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
@@ -10,3 +13,20 @@ class SiteResponse(BaseModel):
location: str | None
capacity_kw: float | None
status: str | None
class SiteCurrentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: Literal["good", "partial", "degraded", "critical"]
+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.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import qualite_ou_critique
CapteurStatus = Literal["ok", "failing"]
OverallStatus = Literal["ok", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
RAISON_VERS_CAPTEUR: dict[str, str] = {
"consumption_sensor_failure": "consumption",
"electrical_sensor_failure": "electrical",
@@ -80,7 +79,7 @@ def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
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)
if overall == "critical":
+57 -1
View File
@@ -1,7 +1,11 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from app.models.energy import Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import DataQuality, qualite_ou_critique
class SiteError(Exception):
@@ -12,9 +16,26 @@ class SiteNotFoundError(SiteError):
pass
@dataclass(frozen=True, slots=True)
class SiteCurrentReading:
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: DataQuality
class SiteService:
def __init__(self, *, sites: SiteRepository) -> None:
def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def list_all(self) -> Sequence[Site]:
return await self._sites.list_all()
@@ -24,3 +45,38 @@ class SiteService:
if site is None:
raise SiteNotFoundError(site_id)
return site
async def current(self, site_id: str) -> SiteCurrentReading:
site = await self.get_by_id(site_id)
derniere = await self._readings.latest_for_site(site_id)
if derniere is None:
return SiteCurrentReading(
timestamp=None,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=None,
consumption_kwh=None,
voltage_v=None,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
null_reasons=[],
data_quality="critical",
)
return SiteCurrentReading(
timestamp=derniere.timestamp,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=derniere.consumption_kw,
consumption_kwh=derniere.consumption_kwh,
voltage_v=derniere.voltage_v,
current_a=derniere.current_a,
power_factor=derniere.power_factor,
temperature_celsius=derniere.temperature_celsius,
humidity_percent=derniere.humidity_percent,
null_reasons=derniere.null_reasons or [],
data_quality=qualite_ou_critique(derniere.data_quality),
)
+2 -9
View File
@@ -1,14 +1,10 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
@dataclass(frozen=True, slots=True)
@@ -58,13 +54,10 @@ class StatsService:
@staticmethod
def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
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"
consommation = None
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
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": {
"get": {
"tags": [
@@ -2297,6 +2384,140 @@
],
"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": {
"properties": {
"site_id": {
+1
View File
@@ -31,6 +31,7 @@ ROUTES_A_ROLE = {
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
("GET", "/api/v1/sites/{site_id}/current"),
("GET", "/api/v1/alerts"),
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
+51 -1
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
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.roles import AccountKind, Role
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:
@@ -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:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self.site = site()
self.actuel = lecture_actuelle()
async def list_all(self) -> list[Site]:
return [self.site]
@@ -46,6 +67,11 @@ class FauxService:
raise self._erreur
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
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
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(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
@@ -88,6 +88,73 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
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(
session: AsyncSession,
) -> None:
+80 -7
View File
@@ -1,8 +1,13 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
import pytest
from app.models.energy import Site
from app.services.site import SiteNotFoundError, SiteService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def site(site_id: str = "site-1") -> 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:
def __init__(self, sites: list[Site]) -> None:
self._sites = sites
@@ -26,24 +46,77 @@ class FakeRepository:
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:
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
class FauxDepotLectures:
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"]
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"
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
service = SiteService(sites=FakeRepository([]))
svc = service([])
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",
"lcov",
"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],
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">
<header class="dashboard__header">
<div class="dashboard__brand">
<ev-brand class="dashboard__logo" />
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="dashboard__logo" />
</a>
<div>
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div>
</div>
<ev-button class="logout-button" variant="secondary" [fullWidth]="false" (click)="onLogout()"
>Déconnexion</ev-button
>
<div class="dashboard__actions">
<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>
@if (error(); as message) {
@@ -34,6 +34,12 @@
color: var(--color-text-muted);
}
.dashboard__actions {
display: flex;
align-items: center;
gap: 1rem;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
@@ -5,7 +5,7 @@ import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
import {AuthService} from '../../core/services/auth.service';
import {Router} from '@angular/router';
import {Router, provideRouter} from '@angular/router';
vi.mock('chart.js', () => {
class ChartMock {
@@ -29,6 +29,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -60,6 +61,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -86,6 +88,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -99,7 +102,6 @@ describe('Dashboard', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
@@ -107,18 +109,21 @@ describe('Dashboard', () => {
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
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', () => {
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'))),
clearSession: vi.fn(),
};
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
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', () => {
@@ -158,6 +164,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ 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 { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe } from '@angular/common';
import { Router } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
@@ -30,7 +30,17 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge, Brand, Button],
imports: [
DecimalPipe,
RouterLink,
ConsumptionGauge,
SiteLoadChart,
Card,
EvAlert,
Badge,
Brand,
Button,
],
templateUrl: './dashboard.html',
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 = {
production: false,
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/forms';
@use 'styles/auth-page';
@use 'styles/links';
body {
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/**',
],
},
},
})
+6 -1
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 |
| 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}/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/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
@@ -171,7 +172,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
/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
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).
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
@@ -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
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
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.
+12 -9
View File
@@ -1,14 +1,17 @@
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
# Dossier contenant les tests
sonar.tests=apps/frontend/src,apps/backend/tests
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
# This is the name and version displayed in the SonarCloud UI.
#sonar.projectName=ProjetPiscine_EnerVision
#sonar.projectVersion=1.0
# Liste des fichiers et dossiers à exclure de l'analyse
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
#sonar.sources=.
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8
# Chemin vers le rapport de couverture de code
# Fichier généré par Pytest
sonar.python.coverage.reportPaths=apps/backend/coverage.xml
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info